Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/app/admin/organizations/organizations.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<div class="container">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h1>Manage Organizations</h1>
<button mat-raised-button color="primary" (click)="createOrganization()">
<mat-icon>add</mat-icon>
Create Organization
</button>
</div>

<div class="mat-elevation-z8">
<table mat-table [dataSource]="organizations" matSort>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let org">{{org.name}}</td>
</ng-container>

<!-- Description Column -->
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Description</th>
<td mat-cell *matCellDef="let org">{{org.description}}</td>
</ng-container>

<!-- Email Column -->
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Email</th>
<td mat-cell *matCellDef="let org">{{org.email}}</td>
</ng-container>

<!-- Status Column -->
<ng-container matColumnDef="is_enabled">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
<td mat-cell *matCellDef="let org">
<mat-icon [style.color]="org.is_enabled ? 'green' : 'red'">
{{org.is_enabled ? 'check_circle' : 'cancel'}}
</mat-icon>
</td>
</ng-container>

<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let org">
<button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="editOrganization(org)">
<mat-icon>edit</mat-icon>
<span>Edit</span>
</button>
<button mat-menu-item (click)="addMember(org)">
<mat-icon>person_add</mat-icon>
<span>Add Member</span>
</button>
<button mat-menu-item (click)="viewMembers(org)">
<mat-icon>people</mat-icon>
<span>View Members</span>
</button>
<button mat-menu-item (click)="deleteOrganization(org)" class="text-red-500">
<mat-icon class="text-red-500">delete</mat-icon>
<span class="text-red-500">Delete</span>
</button>
</mat-menu>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

<mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" aria-label="Select page of organizations"></mat-paginator>
</div>
</div>
</div>
64 changes: 64 additions & 0 deletions src/app/admin/organizations/organizations.component.scss
Original file line number Diff line number Diff line change
@@ -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;
}
92 changes: 92 additions & 0 deletions src/app/admin/organizations/organizations.component.ts
Original file line number Diff line number Diff line change
@@ -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<Organization>;
@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);
}
});
}
}
1 change: 1 addition & 0 deletions src/app/api/api.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const API_URL = '/api';
27 changes: 27 additions & 0 deletions src/app/api/models/organization.ts
Original file line number Diff line number Diff line change
@@ -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()))
);
}
}
28 changes: 28 additions & 0 deletions src/app/api/services/entity.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable()
export abstract class EntityService<T> {
protected abstract readonly endpointFormat: string;

constructor(protected httpClient: HttpClient, protected apiUrl: string) {}

protected get<R>(endpoint: string, params?: any): Observable<R> {
return this.httpClient.get<R>(`${this.apiUrl}/${endpoint}`, { params });
}

protected post<R>(endpoint: string, data: any): Observable<R> {
return this.httpClient.post<R>(`${this.apiUrl}/${endpoint}`, data);
}

protected put<R>(endpoint: string, data: any): Observable<R> {
return this.httpClient.put<R>(`${this.apiUrl}/${endpoint}`, data);
}

protected delete<R>(endpoint: string): Observable<R> {
return this.httpClient.delete<R>(`${this.apiUrl}/${endpoint}`);
}

protected abstract createInstanceFrom(json: any): T;
}
80 changes: 80 additions & 0 deletions src/app/api/services/organization.service.ts
Original file line number Diff line number Diff line change
@@ -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<Organization> {
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<Organization[]> {
return this.httpClient.get<Organization[]>(`${this.apiUrl}/${this.endpointFormat.replace(':id:', '')}`, { params });
}

// Get a single organization
getOrganization(id: number): Observable<Organization> {
return this.httpClient.get<Organization>(`${this.apiUrl}/${this.endpointFormat.replace(':id:', id.toString())}`);
}

// Create a new organization
create(organization: Organization): Observable<Organization> {
return this.httpClient.post<Organization>(`${this.apiUrl}/${this.endpointFormat.replace(':id:', '')}`, { organization });
}

// Update an organization
update(organization: Organization): Observable<Organization> {
return this.httpClient.put<Organization>(
`${this.apiUrl}/${this.endpointFormat.replace(':id:', organization.id.toString())}`,
{ organization }
);
}

// Delete an organization
deleteOrganization(id: number): Observable<void> {
return this.httpClient.delete<void>(`${this.apiUrl}/${this.endpointFormat.replace(':id:', id.toString())}`);
}

// Add a member to an organization
addMember(organizationId: number, userId: number): Observable<Organization> {
return this.httpClient.post<Organization>(
`${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members`,
{ user_id: userId }
);
}

// Remove a member from an organization
removeMember(organizationId: number, userId: number): Observable<void> {
return this.httpClient.delete<void>(
`${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members/${userId}`
);
}

// Get organization members
getMembers(organizationId: number): Observable<User[]> {
return this.httpClient.get<User[]>(
`${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/members`
);
}

// Search users to add to organization
searchUsers(organizationId: number, query: string): Observable<User[]> {
return this.httpClient.get<User[]>(
`${this.apiUrl}/${this.endpointFormat.replace(':id:', organizationId.toString())}/search_users`,
{ params: { query } }
);
}
}
6 changes: 6 additions & 0 deletions src/app/common/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@
<mat-icon matListItemIcon>school</mat-icon>
Manage Units
</button>

<button mat-menu-item uiSref="organizations">
<mat-icon matListItemIcon>building</mat-icon>
Manage Organizations
</button>

@if (currentUser.role === 'Admin') {
<button mat-menu-item uiSref="admin/users">
<mat-icon matListItemIcon>people</mat-icon>
Expand Down
Loading