diff --git a/src/app/admin/organizations/organizations.component.html b/src/app/admin/organizations/organizations.component.html new file mode 100644 index 0000000000..394124a1cd --- /dev/null +++ b/src/app/admin/organizations/organizations.component.html @@ -0,0 +1,76 @@ +
+
+
+

Manage Organizations

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{org.name}}Description{{org.description}}Email{{org.email}}Status + + {{org.is_enabled ? 'check_circle' : 'cancel'}} + + Actions + + + + + + + +
+ + +
+
+
\ No newline at end of file diff --git a/src/app/admin/organizations/organizations.component.scss b/src/app/admin/organizations/organizations.component.scss new file mode 100644 index 0000000000..11b38d0706 --- /dev/null +++ b/src/app/admin/organizations/organizations.component.scss @@ -0,0 +1,64 @@ +.container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +table { + width: 100%; +} + +.mat-column-actions { + width: 120px; + text-align: center; +} + +.mat-column-is_enabled { + width: 100px; + text-align: center; +} + +.mat-column-description { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mat-column-email { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mat-mdc-row:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.mat-mdc-header-row { + background-color: #f5f5f5; +} + +.mat-mdc-header-cell { + font-weight: 600; + color: rgba(0, 0, 0, 0.87); +} + +.mat-mdc-cell { + padding: 0 16px; +} + +.mat-mdc-header-cell { + padding: 0 16px; +} + +.mat-mdc-menu-item { + .mat-icon { + margin-right: 8px; + } +} + +.text-red-500 { + color: #ef4444; +} \ No newline at end of file diff --git a/src/app/admin/organizations/organizations.component.ts b/src/app/admin/organizations/organizations.component.ts new file mode 100644 index 0000000000..12602e5def --- /dev/null +++ b/src/app/admin/organizations/organizations.component.ts @@ -0,0 +1,92 @@ +import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'; +import { MatTable } from '@angular/material/table'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { MatDialog } from '@angular/material/dialog'; +import { Organization } from 'src/app/api/models/organization'; +import { OrganizationService } from 'src/app/api/services/organization.service'; +import { AlertService } from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-organizations', + templateUrl: './organizations.component.html', + styleUrls: ['./organizations.component.scss'] +}) +export class OrganizationsComponent implements OnInit, AfterViewInit { + displayedColumns: string[] = ['name', 'description', 'email', 'is_enabled', 'actions']; + organizations: Organization[] = []; + loading: boolean = true; + + @ViewChild(MatTable) table: MatTable; + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + constructor( + private organizationService: OrganizationService, + private alertService: AlertService, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.loadOrganizations(); + } + + ngAfterViewInit() { + // Add sorting and pagination if needed + } + + loadOrganizations(): void { + this.loading = true; + this.organizationService.query().subscribe({ + next: (orgs) => { + this.organizations = orgs; + this.loading = false; + if (this.table) { + this.table.renderRows(); + } + }, + error: (err) => { + this.alertService.error('Failed to load organizations: ' + err); + this.loading = false; + } + }); + } + + createOrganization(): void { + // TODO: Implement create organization dialog + } + + editOrganization(organization: Organization): void { + // TODO: Implement edit organization dialog + } + + deleteOrganization(organization: Organization): void { + // TODO: Implement delete organization confirmation dialog + this.organizationService.deleteOrganization(organization.id).subscribe({ + next: () => { + this.alertService.success('Organization deleted successfully'); + this.loadOrganizations(); + }, + error: (err) => { + this.alertService.error('Failed to delete organization: ' + err); + } + }); + } + + addMember(organization: Organization): void { + // TODO: Implement add member dialog + } + + viewMembers(organization: Organization): void { + // TODO: Implement view members dialog + this.organizationService.getMembers(organization.id).subscribe({ + next: (members) => { + // TODO: Show members in dialog + console.log('Members:', members); + }, + error: (err) => { + this.alertService.error('Failed to load members: ' + err); + } + }); + } +} \ No newline at end of file diff --git a/src/app/api/api.config.ts b/src/app/api/api.config.ts new file mode 100644 index 0000000000..9e9fefd08c --- /dev/null +++ b/src/app/api/api.config.ts @@ -0,0 +1 @@ +export const API_URL = '/api'; \ No newline at end of file diff --git a/src/app/api/models/organization.ts b/src/app/api/models/organization.ts new file mode 100644 index 0000000000..0aa57fb7e5 --- /dev/null +++ b/src/app/api/models/organization.ts @@ -0,0 +1,27 @@ +import { EntityService } from '../services/entity.service'; +import { User } from './user/user'; + +export class Organization { + id: number; + name: string; + description: string; + email: string; + is_enabled: boolean; + users: User[]; + + constructor(params: any) { + this.id = params.id; + this.name = params.name; + this.description = params.description; + this.email = params.email; + this.is_enabled = params.is_enabled; + this.users = params.users || []; + } + + matches(filter: string): boolean { + return ( + this.name.toLowerCase().includes(filter.toLowerCase()) || + (this.description && this.description.toLowerCase().includes(filter.toLowerCase())) + ); + } +} \ No newline at end of file diff --git a/src/app/api/services/entity.service.ts b/src/app/api/services/entity.service.ts new file mode 100644 index 0000000000..de0cc34728 --- /dev/null +++ b/src/app/api/services/entity.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +@Injectable() +export abstract class EntityService { + protected abstract readonly endpointFormat: string; + + constructor(protected httpClient: HttpClient, protected apiUrl: string) {} + + protected get(endpoint: string, params?: any): Observable { + return this.httpClient.get(`${this.apiUrl}/${endpoint}`, { params }); + } + + protected post(endpoint: string, data: any): Observable { + return this.httpClient.post(`${this.apiUrl}/${endpoint}`, data); + } + + protected put(endpoint: string, data: any): Observable { + return this.httpClient.put(`${this.apiUrl}/${endpoint}`, data); + } + + protected delete(endpoint: string): Observable { + return this.httpClient.delete(`${this.apiUrl}/${endpoint}`); + } + + protected abstract createInstanceFrom(json: any): T; +} \ No newline at end of file diff --git a/src/app/api/services/organization.service.ts b/src/app/api/services/organization.service.ts new file mode 100644 index 0000000000..007f3a1664 --- /dev/null +++ b/src/app/api/services/organization.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; +import { EntityService } from './entity.service'; +import { Organization } from '../models/organization'; +import { HttpClient } from '@angular/common/http'; +import { API_URL } from '../api.config'; +import { User } from '../models/user/user'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class OrganizationService extends EntityService { + protected readonly endpointFormat = 'organizations/:id:'; + + constructor(httpClient: HttpClient) { + super(httpClient, API_URL); + } + + protected createInstanceFrom(json: any): Organization { + return new Organization(json); + } + + // Get all organizations + query(params?: any): Observable { + return this.httpClient.get(`${this.apiUrl}/${this.endpointFormat.replace(':id:', '')}`, { params }); + } + + // Get a single organization + getOrganization(id: number): Observable { + return this.httpClient.get(`${this.apiUrl}/${this.endpointFormat.replace(':id:', id.toString())}`); + } + + // Create a new organization + create(organization: Organization): Observable { + return this.httpClient.post(`${this.apiUrl}/${this.endpointFormat.replace(':id:', '')}`, { organization }); + } + + // Update an organization + update(organization: Organization): Observable { + return this.httpClient.put( + `${this.apiUrl}/${this.endpointFormat.replace(':id:', organization.id.toString())}`, + { organization } + ); + } + + // Delete an organization + deleteOrganization(id: number): Observable { + return this.httpClient.delete(`${this.apiUrl}/${this.endpointFormat.replace(':id:', id.toString())}`); + } + + // Add a member to an organization + addMember(organizationId: number, userId: number): Observable { + return this.httpClient.post( + `${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members`, + { user_id: userId } + ); + } + + // Remove a member from an organization + removeMember(organizationId: number, userId: number): Observable { + return this.httpClient.delete( + `${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members/${userId}` + ); + } + + // Get organization members + getMembers(organizationId: number): Observable { + return this.httpClient.get( + `${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members` + ); + } + + // Search users to add to organization + searchUsers(organizationId: number, query: string): Observable { + return this.httpClient.get( + `${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/search_users`, + { params: { query } } + ); + } +} \ No newline at end of file diff --git a/src/app/common/header/header.component.html b/src/app/common/header/header.component.html index bac6b77c07..182a59cb18 100644 --- a/src/app/common/header/header.component.html +++ b/src/app/common/header/header.component.html @@ -57,6 +57,12 @@ school Manage Units + + + @if (currentUser.role === 'Admin') {