/** * 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 = {}; memberPhotoRaw: Record = {}; // committee drawer state showForm = false; editingId: number | null = null; saving = false; submitted = false; form: any = this.emptyForm(); // meeting state meetingsByCommittee: Record = {}; meetingsLoading: Set = new Set(); expandedMeetings: Set = new Set(); // meeting drawer state showMeetingForm = false; editingMeetingId: number | null = null; currentMeetingCommitteeId: number | null = null; savingMeeting = false; submittedMeeting = false; meetingForm: any = this.emptyMeetingForm(); 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; 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: () => { if (this.editingId != null) { 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)); toRemove.forEach((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'; } // ── Meetings ────────────────────────────────────────────────────── toggleMeetings(committee: any): void { if (this.expandedMeetings.has(committee.id)) { this.expandedMeetings.delete(committee.id); } else { this.expandedMeetings.add(committee.id); if (!this.meetingsByCommittee[committee.id]) { this.loadMeetings(committee.id); } } } isMeetingsExpanded(committeeId: number): boolean { return this.expandedMeetings.has(committeeId); } loadMeetings(committeeId: number): void { this.meetingsLoading.add(committeeId); this.service.getMeetings(committeeId).subscribe({ next: (data) => { this.meetingsByCommittee[committeeId] = data ?? []; this.meetingsLoading.delete(committeeId); }, error: () => { this.meetingsByCommittee[committeeId] = []; this.meetingsLoading.delete(committeeId); } }); } isMeetingsLoading(committeeId: number): boolean { return this.meetingsLoading.has(committeeId); } getMeetings(committeeId: number): any[] { return this.meetingsByCommittee[committeeId] ?? []; } openScheduleMeeting(committee: any, event: MouseEvent): void { event.stopPropagation(); this.editingMeetingId = null; this.currentMeetingCommitteeId = committee.id; this.submittedMeeting = false; this.meetingForm = this.emptyMeetingForm(); this.showMeetingForm = true; } openEditMeeting(meeting: any, committeeId: number, event: MouseEvent): void { event.stopPropagation(); this.editingMeetingId = meeting.id; this.currentMeetingCommitteeId = committeeId; this.submittedMeeting = false; this.meetingForm = { title: meeting.title, meetingDate: meeting.meetingDate, meetingTime: meeting.meetingTime ?? '', mode: meeting.mode ?? 'OFFLINE', agenda: meeting.agenda ?? '', platform: meeting.platform ?? '', meetingLink: meeting.meetingLink ?? '', meetingIdRef: meeting.meetingIdRef ?? '', meetingPassword: meeting.meetingPassword ?? '', venue: meeting.venue ?? '', address: meeting.address ?? '', city: meeting.city ?? '' }; this.showMeetingForm = true; } closeMeetingForm(): void { this.showMeetingForm = false; this.editingMeetingId = null; this.currentMeetingCommitteeId = null; this.meetingForm = this.emptyMeetingForm(); this.submittedMeeting = false; } saveMeeting(): void { this.submittedMeeting = true; if (!this.meetingForm.title?.trim() || !this.meetingForm.meetingDate) return; this.savingMeeting = true; const payload: any = { title: this.meetingForm.title.trim(), meetingDate: this.meetingForm.meetingDate, meetingTime: this.meetingForm.meetingTime || null, mode: this.meetingForm.mode, agenda: this.meetingForm.agenda?.trim() || null }; if (this.meetingForm.mode === 'ONLINE') { payload.platform = this.meetingForm.platform || null; payload.meetingLink = this.meetingForm.meetingLink || null; payload.meetingIdRef = this.meetingForm.meetingIdRef || null; payload.meetingPassword = this.meetingForm.meetingPassword || null; } else { payload.venue = this.meetingForm.venue || null; payload.address = this.meetingForm.address || null; payload.city = this.meetingForm.city || null; } const committeeId = this.currentMeetingCommitteeId!; const call = this.editingMeetingId != null ? this.service.updateMeeting(this.editingMeetingId, payload) : this.service.scheduleMeeting(committeeId, payload); call.subscribe({ next: () => { this.savingMeeting = false; this.closeMeetingForm(); this.loadMeetings(committeeId); this.snackBar.open(this.editingMeetingId != null ? 'Meeting updated!' : 'Meeting scheduled!', 'Close', { duration: 3000 }); }, error: (err) => { this.savingMeeting = false; const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Operation failed'; this.snackBar.open(msg, 'Close', { duration: 5000 }); } }); } confirmDeleteMeeting(meeting: any, committeeId: number, event: MouseEvent): void { event.stopPropagation(); if (!confirm(`Delete meeting "${meeting.title}"?`)) return; this.service.deleteMeeting(meeting.id).subscribe({ next: () => { this.loadMeetings(committeeId); this.snackBar.open('Meeting deleted.', 'Close', { duration: 3000 }); }, error: (err) => { const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Delete failed'; this.snackBar.open(msg, 'Close', { duration: 4000 }); } }); } patchMeetingStatus(meeting: any, committeeId: number, status: string, event: MouseEvent): void { event.stopPropagation(); this.service.patchMeetingStatus(meeting.id, status).subscribe({ next: () => { this.loadMeetings(committeeId); this.snackBar.open(`Marked as ${status}.`, 'Close', { duration: 3000 }); }, error: () => this.snackBar.open('Status update failed.', 'Close', { duration: 3000 }) }); } meetingStatusColor(status: string): string { const map: Record = { SCHEDULED: '#2563eb', COMPLETED: '#10b981', CANCELLED: '#94a3b8' }; return map[status] ?? '#64748b'; } meetingModeIcon(mode: string): string { return mode === 'ONLINE' ? 'video_call' : 'location_on'; } private emptyForm(): any { return { name: '', description: '', image: null, selectedIds: [] }; } private emptyMeetingForm(): any { return { title: '', meetingDate: '', meetingTime: '', mode: 'OFFLINE', agenda: '', platform: '', meetingLink: '', meetingIdRef: '', meetingPassword: '', venue: '', address: '', city: '' }; } }