- committee-board: collapsible meetings section per card, Schedule Meeting button, meeting list with status chips and join links - Meeting form drawer: ONLINE (platform, link, ID, password) vs OFFLINE (venue, address, city) mode toggle - Actions: edit/complete/cancel for SCHEDULED; delete (soft) for all statuses on active committees - election.service.ts: getMeetings, scheduleMeeting, updateMeeting, patchMeetingStatus, deleteMeeting - election-audit-log: meeting action metas (SCHEDULE_MEETING, UPDATE_MEETING, CANCEL_MEETING, COMPLETE_MEETING, DELETE_MEETING + FAILEDs), COMMITTEE entity action list updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
/**
|
|
* 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> = {};
|
|
|
|
// committee drawer state
|
|
showForm = false;
|
|
editingId: number | null = null;
|
|
saving = false;
|
|
submitted = false;
|
|
form: any = this.emptyForm();
|
|
|
|
// meeting state
|
|
meetingsByCommittee: Record<number, any[]> = {};
|
|
meetingsLoading: Set<number> = new Set();
|
|
expandedMeetings: Set<number> = 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<string, string> = { 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: ''
|
|
};
|
|
}
|
|
}
|