feat(election): committee management module with image upload, zoom viewer, and member profiles
- Add Committee Board component: create/edit committees with profile image upload - Image viewer: redesigned zoom dialog with frosted glass toolbar, SVG icons, dark theme - Committee profile image: circular upload ring, full-size zoom via ImagePreviewDialogComponent - Member photos: clickable avatars open zoom viewer in both stacked and list views - Backend: committee API service methods (CRUD, members, dissolve, current directors) - Route and navigation link added to election dashboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c4387c5c79
commit
83e9fc9e68
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { ElectionService } from '../../election.service';
|
||||
import { ImagePreviewDialogComponent } from '../../../cashier/transaction-entry/transaction-entry.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-committee-board',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './committee-board.component.html',
|
||||
styleUrls: ['./committee-board.component.scss']
|
||||
})
|
||||
export class CommitteeBoardComponent implements OnInit {
|
||||
private service = inject(ElectionService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
committees: any[] = [];
|
||||
loading = true;
|
||||
|
||||
// available directors from current election
|
||||
availableDirectors: any[] = [];
|
||||
directorsLoading = false;
|
||||
memberPhotoMap: Record<number, SafeResourceUrl | null> = {};
|
||||
memberPhotoRaw: Record<number, string> = {};
|
||||
|
||||
// drawer state
|
||||
showForm = false;
|
||||
editingId: number | null = null;
|
||||
saving = false;
|
||||
submitted = false;
|
||||
|
||||
form: any = this.emptyForm();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service.getCommittees().subscribe({
|
||||
next: (data) => {
|
||||
this.committees = data ?? [];
|
||||
this.loading = false;
|
||||
this.loadMemberPhotos();
|
||||
},
|
||||
error: () => {
|
||||
this.committees = [];
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadMemberPhotos(): void {
|
||||
for (const c of this.committees) {
|
||||
for (const m of c.members ?? []) {
|
||||
if (m.memberId && this.memberPhotoMap[m.memberId] === undefined) {
|
||||
this.service.getClientImage(m.memberId).subscribe((base64) => {
|
||||
if (base64) {
|
||||
this.memberPhotoRaw[m.memberId] = base64;
|
||||
this.memberPhotoMap[m.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
|
||||
} else {
|
||||
this.memberPhotoMap[m.memberId] = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMemberPhoto(memberId: number): SafeResourceUrl | null {
|
||||
return this.memberPhotoMap[memberId] ?? null;
|
||||
}
|
||||
|
||||
openCreate(): void {
|
||||
this.editingId = null;
|
||||
this.submitted = false;
|
||||
this.form = this.emptyForm();
|
||||
this.loadAvailableDirectors();
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
openEdit(committee: any): void {
|
||||
this.editingId = committee.id;
|
||||
this.submitted = false;
|
||||
this.form = {
|
||||
name: committee.name,
|
||||
description: committee.description ?? '',
|
||||
image: committee.image ?? null,
|
||||
selectedIds: (committee.members ?? []).map((m: any) => m.directorAppointmentId)
|
||||
};
|
||||
this.loadAvailableDirectors();
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
private loadAvailableDirectors(): void {
|
||||
this.directorsLoading = true;
|
||||
this.service.getCurrentElectionDirectors().subscribe((data) => {
|
||||
this.availableDirectors = data ?? [];
|
||||
this.directorsLoading = false;
|
||||
// load photos for selection list
|
||||
for (const d of this.availableDirectors) {
|
||||
if (d.memberId && this.memberPhotoMap[d.memberId] === undefined) {
|
||||
this.service.getClientImage(d.memberId).subscribe((base64) => {
|
||||
if (base64) {
|
||||
this.memberPhotoRaw[d.memberId] = base64;
|
||||
this.memberPhotoMap[d.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
|
||||
} else {
|
||||
this.memberPhotoMap[d.memberId] = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onImageSelect(event: Event): void {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.form.image = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
removeImage(): void {
|
||||
this.form.image = null;
|
||||
}
|
||||
|
||||
openImageViewer(url: string, title: string, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (!url) return;
|
||||
this.dialog.open(ImagePreviewDialogComponent, {
|
||||
data: { url, title },
|
||||
panelClass: 'ipd-dialog',
|
||||
backdropClass: 'ipd-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
openMemberPhotoViewer(memberId: number, name: string, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
const url = this.memberPhotoRaw[memberId];
|
||||
if (!url) return;
|
||||
this.dialog.open(ImagePreviewDialogComponent, {
|
||||
data: { url, title: name },
|
||||
panelClass: 'ipd-dialog',
|
||||
backdropClass: 'ipd-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
toggleDirector(dirId: number): void {
|
||||
const idx = this.form.selectedIds.indexOf(dirId);
|
||||
if (idx >= 0) {
|
||||
this.form.selectedIds.splice(idx, 1);
|
||||
} else {
|
||||
this.form.selectedIds.push(dirId);
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(dirId: number): boolean {
|
||||
return this.form.selectedIds.includes(dirId);
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm = false;
|
||||
this.editingId = null;
|
||||
this.form = this.emptyForm();
|
||||
this.submitted = false;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.submitted = true;
|
||||
if (!this.form.name?.trim()) return;
|
||||
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
name: this.form.name.trim(),
|
||||
description: this.form.description?.trim() || null,
|
||||
image: this.form.image || null,
|
||||
directorAppointmentIds: this.form.selectedIds
|
||||
};
|
||||
|
||||
const call =
|
||||
this.editingId != null
|
||||
? this.service.updateCommittee(this.editingId, payload)
|
||||
: this.service.createCommittee(payload);
|
||||
|
||||
call.subscribe({
|
||||
next: (result) => {
|
||||
if (this.editingId != null) {
|
||||
// sync members separately if editing
|
||||
const existing = this.committees.find((c) => c.id === this.editingId);
|
||||
const existingIds: number[] = (existing?.members ?? []).map((m: any) => m.directorAppointmentId);
|
||||
const toAdd = this.form.selectedIds.filter((id: number) => !existingIds.includes(id));
|
||||
const toRemove = existingIds.filter((id: number) => !this.form.selectedIds.includes(id));
|
||||
|
||||
const removes = toRemove.map((id: number) =>
|
||||
this.service.removeCommitteeMember(this.editingId!, id).subscribe()
|
||||
);
|
||||
if (toAdd.length > 0) {
|
||||
this.service.addCommitteeMembers(this.editingId!, toAdd).subscribe();
|
||||
}
|
||||
}
|
||||
this.saving = false;
|
||||
this.closeForm();
|
||||
setTimeout(() => this.load(), 300);
|
||||
this.snackBar.open(this.editingId != null ? 'Committee updated!' : 'Committee created!', 'Close', {
|
||||
duration: 3000
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed';
|
||||
this.snackBar.open(msg, 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmDissolve(committee: any): void {
|
||||
if (!confirm(`Dissolve committee "${committee.name}"? This cannot be undone.`)) return;
|
||||
this.service.dissolveCommittee(committee.id).subscribe({
|
||||
next: () => {
|
||||
this.load();
|
||||
this.snackBar.open('Committee dissolved.', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to dissolve.', 'Close', { duration: 3000 })
|
||||
});
|
||||
}
|
||||
|
||||
statusColor(status: string): string {
|
||||
return status === 'ACTIVE' ? '#10b981' : '#94a3b8';
|
||||
}
|
||||
|
||||
private emptyForm(): any {
|
||||
return { name: '', description: '', image: null, selectedIds: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user