feat(election): committee meeting UI — schedule/edit drawer, online/offline mode toggle, soft delete

- 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>
This commit is contained in:
Jaanu423
2026-07-06 10:18:00 +05:30
co-authored by Claude Sonnet 4.6
parent 3e51eae4b5
commit e952bc546f
5 changed files with 853 additions and 15 deletions
@@ -46,14 +46,26 @@ export class CommitteeBoardComponent implements OnInit {
memberPhotoMap: Record<number, SafeResourceUrl | null> = {};
memberPhotoRaw: Record<number, string> = {};
// drawer state
// 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();
}
@@ -120,7 +132,6 @@ export class CommitteeBoardComponent implements OnInit {
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) => {
@@ -209,17 +220,13 @@ export class CommitteeBoardComponent implements OnInit {
: this.service.createCommittee(payload);
call.subscribe({
next: (result) => {
next: () => {
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()
);
toRemove.forEach((id: number) => this.service.removeCommitteeMember(this.editingId!, id).subscribe());
if (toAdd.length > 0) {
this.service.addCommitteeMembers(this.editingId!, toAdd).subscribe();
}
@@ -254,7 +261,184 @@ export class CommitteeBoardComponent implements OnInit {
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: ''
};
}
}