From e952bc546fb46af5ba77d959ba4b84e906bf88da Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Mon, 6 Jul 2026 10:18:00 +0530 Subject: [PATCH] =?UTF-8?q?feat(election):=20committee=20meeting=20UI=20?= =?UTF-8?q?=E2=80=94=20schedule/edit=20drawer,=20online/offline=20mode=20t?= =?UTF-8?q?oggle,=20soft=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../committee-board.component.html | 247 +++++++++++- .../committee-board.component.scss | 365 ++++++++++++++++++ .../committee-board.component.ts | 202 +++++++++- .../election-audit-log.component.ts | 29 +- src/app/election/election.service.ts | 25 ++ 5 files changed, 853 insertions(+), 15 deletions(-) diff --git a/src/app/election/components/committee-board/committee-board.component.html b/src/app/election/components/committee-board/committee-board.component.html index 6da5566b2..9654ecb5c 100644 --- a/src/app/election/components/committee-board/committee-board.component.html +++ b/src/app/election/components/committee-board/committee-board.component.html @@ -116,6 +116,122 @@ } + +
+ + + @if (isMeetingsExpanded(c.id)) { +
+ @if (c.status === 'ACTIVE') { + + } + + @if (isMeetingsLoading(c.id)) { +
+
+ Loading... +
+ } @else if (getMeetings(c.id).length === 0) { +
+ event_busy + No meetings scheduled +
+ } @else { +
+ @for (m of getMeetings(c.id); track m.id) { +
+
+ {{ meetingModeIcon(m.mode) }} +
+
+ {{ m.title }} +
+ calendar_today + {{ m.meetingDate | date: 'dd MMM yyyy' }} + @if (m.meetingTime) { + · + schedule + {{ m.meetingTime }} + } + · + {{ m.status }} +
+ @if (m.mode === 'ONLINE' && m.platform) { +
+ videocam + {{ m.platform }} + @if (m.meetingLink) { + + open_in_new Join + + } +
+ } + @if (m.mode === 'OFFLINE' && m.venue) { +
+ place + {{ m.venue }}{{ m.city ? ', ' + m.city : '' }} +
+ } +
+ @if (c.status === 'ACTIVE') { +
+ @if (m.status === 'SCHEDULED') { + + + + } + +
+ } +
+ } +
+ } +
+ } +
+ +
@if (c.status === 'ACTIVE') { @@ -130,7 +246,7 @@ }
- + @if (showForm) {
@@ -167,7 +283,6 @@
-
-
Select Members (Current Election Directors)
@if (directorsLoading) { @@ -239,3 +353,130 @@ } + + +@if (showMeetingForm) { +
+
+
+

{{ editingMeetingId ? 'Edit Meeting' : 'Schedule Meeting' }}

+ +
+ +
+ +
+ + + @if (submittedMeeting && !meetingForm.title?.trim()) { + Title is required + } +
+ + +
+
+ + + @if (submittedMeeting && !meetingForm.meetingDate) { + Date is required + } +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + @if (meetingForm.mode === 'ONLINE') { +
+
video_call Online Details
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ } + + + @if (meetingForm.mode === 'OFFLINE') { +
+
location_on Venue Details
+
+ + +
+
+ + +
+
+ + +
+
+ } + + +
+ + +
+
+ + +
+} diff --git a/src/app/election/components/committee-board/committee-board.component.scss b/src/app/election/components/committee-board/committee-board.component.scss index 9a772f529..fa4a93173 100644 --- a/src/app/election/components/committee-board/committee-board.component.scss +++ b/src/app/election/components/committee-board/committee-board.component.scss @@ -883,6 +883,367 @@ $font: } } +// ── Meetings Section ────────────────────────────────────────────── +.meetings-section { + border-top: $border; + margin-top: 4px; +} + +.meetings-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 10px 4px; + background: none; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: $sub; + text-align: left; + transition: color 0.15s; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + color: $sub; + } + + .chevron { + margin-left: auto; + font-size: 18px; + height: 18px; + width: 18px; + transition: transform 0.2s; + } + + &.expanded { + color: $primary; + + mat-icon { + color: $primary; + } + } + + &:hover { + color: $text; + } +} + +.meeting-count-badge { + background: $primary; + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +.meetings-panel { + padding: 4px 0 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.btn-schedule { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: 1px dashed #a5b4fc; + border-radius: 8px; + background: rgb(99 102 241 / 4%); + color: $primary; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + align-self: flex-start; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + } + + &:hover { + background: rgb(99 102 241 / 10%); + border-color: $primary; + } +} + +.meetings-empty { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 0; + font-size: 12px; + color: #94a3b8; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } +} + +.meeting-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.meeting-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + background: #f8fafc; + border-radius: 8px; + border: $border; +} + +.meeting-mode-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgb(99 102 241 / 10%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + color: $primary; + } +} + +.meeting-info { + flex: 1; + min-width: 0; +} + +.meeting-title { + display: block; + font-size: 13px; + font-weight: 600; + color: $text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.meeting-meta-row { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-top: 3px; + font-size: 11px; + color: $sub; + + .meta-icon { + font-size: 11px; + height: 11px; + width: 11px; + } + + .meta-sep { + color: #cbd5e1; + } +} + +.meeting-status-chip { + font-size: 9px; + font-weight: 700; + padding: 1px 6px; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.meeting-platform-row { + display: flex; + align-items: center; + gap: 4px; + margin-top: 3px; + font-size: 11px; + color: $sub; + + .meta-icon { + font-size: 11px; + height: 11px; + width: 11px; + } +} + +.join-link { + display: inline-flex; + align-items: center; + gap: 2px; + color: $primary; + font-size: 11px; + font-weight: 600; + text-decoration: none; + margin-left: 4px; + + mat-icon { + font-size: 11px; + height: 11px; + width: 11px; + } + + &:hover { + text-decoration: underline; + } +} + +.meeting-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.btn-icon { + width: 24px; + height: 24px; + border-radius: 6px; + border: none; + background: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: $sub; + transition: all 0.15s; + + mat-icon { + font-size: 14px; + height: 14px; + width: 14px; + } + + &:hover { + background: #f1f5f9; + color: $text; + } + + &.green:hover { + background: #dcfce7; + color: #10b981; + } + + &.red:hover { + background: #fef9c3; + color: #f59e0b; + } + + &.danger:hover { + background: #fee2e2; + color: #ef4444; + } +} + +// ── Meeting Drawer ──────────────────────────────────────────────── +.meeting-drawer { + width: 500px; +} + +.mode-toggle { + display: flex; + gap: 8px; +} + +.mode-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px; + border: $border; + border-radius: 10px; + background: #fff; + font-size: 13px; + font-weight: 600; + color: $sub; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &.active { + border-color: $primary; + background: rgb(99 102 241 / 6%); + color: $primary; + } + + &:hover:not(.active) { + background: #f8fafc; + } +} + +.mode-fields-group { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border-radius: 10px; + border: $border; + background: #f8fafc; +} + +.mode-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } + + &.online-label { + color: #2563eb; + + mat-icon { + color: #2563eb; + } + } + + &.offline-label { + color: #16a34a; + + mat-icon { + color: #16a34a; + } + } +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + @media (width <= 600px) { .committee-page { padding: 16px; @@ -895,4 +1256,8 @@ $font: .form-drawer { width: 100vw; } + + .form-row-2 { + grid-template-columns: 1fr; + } } diff --git a/src/app/election/components/committee-board/committee-board.component.ts b/src/app/election/components/committee-board/committee-board.component.ts index 325338a9d..c0de360ca 100644 --- a/src/app/election/components/committee-board/committee-board.component.ts +++ b/src/app/election/components/committee-board/committee-board.component.ts @@ -46,14 +46,26 @@ export class CommitteeBoardComponent implements OnInit { memberPhotoMap: Record = {}; memberPhotoRaw: Record = {}; - // drawer state + // 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(); } @@ -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 = { 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: '' + }; + } } diff --git a/src/app/election/components/election-audit-log/election-audit-log.component.ts b/src/app/election/components/election-audit-log/election-audit-log.component.ts index e69b94690..a77cebe43 100644 --- a/src/app/election/components/election-audit-log/election-audit-log.component.ts +++ b/src/app/election/components/election-audit-log/election-audit-log.component.ts @@ -95,7 +95,15 @@ const DETAIL_LABELS: Record = { committeeId: 'Committee ID', members: 'Members', addedIds: 'Added IDs', - removedAppointmentId: 'Removed Appointment ID' + removedAppointmentId: 'Removed Appointment ID', + // Committee Meeting + meetingId: 'Meeting ID', + meetingTitle: 'Meeting Title', + meetingDate: 'Meeting Date', + mode: 'Mode', + platform: 'Platform', + venue: 'Venue', + city: 'City' }; const ACTION_META: Record = { @@ -156,7 +164,15 @@ const ACTION_META: Record { return this.http.post(`/election/committees/${id}/dissolve`, {}); } + + // ── Committee Meetings ───────────────────────────────────────── + getMeetings(committeeId: number): Observable { + return this.http.get(`/election/committee-meetings/committee/${committeeId}`).pipe(catchError(() => of([]))); + } + + getMeeting(meetingId: number): Observable { + return this.http.get(`/election/committee-meetings/${meetingId}`); + } + + scheduleMeeting(committeeId: number, data: any): Observable { + return this.http.post(`/election/committee-meetings/committee/${committeeId}`, data); + } + + updateMeeting(meetingId: number, data: any): Observable { + return this.http.put(`/election/committee-meetings/${meetingId}`, data); + } + + patchMeetingStatus(meetingId: number, status: string): Observable { + return this.http.patch(`/election/committee-meetings/${meetingId}/status`, { status }); + } + + deleteMeeting(meetingId: number): Observable { + return this.http.delete(`/election/committee-meetings/${meetingId}`); + } }