feat(election): relational agenda items, PDF download, email redesign, and nav fixes

- Replace flat agenda text field with relational agenda items (add/remove rows)
- Fix edit form pre-populating saved agenda using [value]+(input) binding
- Add PDF download button: opens print-ready window with full meeting details
- Redesign meeting invite email: table-based layout, compact header, amber agenda
  box with numbered badges, two-column footer
- Fix stale email agenda bug: refresh entity after agenda save to bypass EclipseLink
  L1 identity map cache
- Remove permission wrapper from Election sidenav item; add routerLink to toolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-06 16:54:04 +05:30
co-authored by Claude Sonnet 4.6
parent e952bc546f
commit 8a1b7d5d6c
5 changed files with 371 additions and 48 deletions
@@ -182,22 +182,20 @@
</ng-container>
<!-- Election Management Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_ELECTION'">
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
</ng-container>
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
<!-- RBI Integration Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
@@ -229,7 +229,7 @@
<fa-icon icon="users" class="ai-col-h-ic"></fa-icon>
<span>Governance</span>
</div>
<button mat-menu-item class="ai-card" tabindex="0">
<button mat-menu-item class="ai-card" routerLink="/election" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="users"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">Election Management</span>
@@ -192,9 +192,23 @@
<span>{{ m.venue }}{{ m.city ? ', ' + m.city : '' }}</span>
</div>
}
@if (m.agendaItems?.length > 0) {
<div class="meeting-agenda-preview">
<mat-icon class="meta-icon">format_list_bulleted</mat-icon>
<span
>{{ m.agendaItems.length }} agenda item{{ m.agendaItems.length !== 1 ? 's' : '' }}</span
>
</div>
}
</div>
@if (c.status === 'ACTIVE') {
<div class="meeting-actions">
<div class="meeting-actions">
<button class="btn-icon" title="Copy Details" (click)="copyMeetingDetails(m, $event)">
<mat-icon>content_copy</mat-icon>
</button>
<button class="btn-icon" title="Download PDF" (click)="downloadMeetingPdf(m, c, $event)">
<mat-icon>picture_as_pdf</mat-icon>
</button>
@if (c.status === 'ACTIVE') {
@if (m.status === 'SCHEDULED') {
<button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)">
<mat-icon>edit</mat-icon>
@@ -221,8 +235,8 @@
>
<mat-icon>delete_outline</mat-icon>
</button>
</div>
}
}
</div>
</div>
}
</div>
@@ -371,10 +385,11 @@
type="text"
[(ngModel)]="meetingForm.title"
placeholder="e.g. Q1 Strategy Review"
[class.input-error]="submittedMeeting && !meetingForm.title?.trim()"
maxlength="200"
[class.input-error]="submittedMeeting && meetingErrors['title']"
/>
@if (submittedMeeting && !meetingForm.title?.trim()) {
<span class="error-msg">Title is required</span>
@if (submittedMeeting && meetingErrors['title']) {
<span class="error-msg">{{ meetingErrors['title'] }}</span>
}
</div>
@@ -385,10 +400,11 @@
<input
type="date"
[(ngModel)]="meetingForm.meetingDate"
[class.input-error]="submittedMeeting && !meetingForm.meetingDate"
[min]="todayDate"
[class.input-error]="submittedMeeting && meetingErrors['meetingDate']"
/>
@if (submittedMeeting && !meetingForm.meetingDate) {
<span class="error-msg">Date is required</span>
@if (submittedMeeting && meetingErrors['meetingDate']) {
<span class="error-msg">{{ meetingErrors['meetingDate'] }}</span>
}
</div>
<div class="form-group">
@@ -430,7 +446,15 @@
</div>
<div class="form-group">
<label>Meeting Link</label>
<input type="url" [(ngModel)]="meetingForm.meetingLink" placeholder="https://..." />
<input
type="url"
[(ngModel)]="meetingForm.meetingLink"
placeholder="https://..."
[class.input-error]="submittedMeeting && meetingErrors['meetingLink']"
/>
@if (submittedMeeting && meetingErrors['meetingLink']) {
<span class="error-msg">{{ meetingErrors['meetingLink'] }}</span>
}
</div>
<div class="form-row-2">
<div class="form-group">
@@ -464,10 +488,31 @@
</div>
}
<!-- Agenda -->
<!-- Agenda Items -->
<div class="form-group">
<label>Agenda</label>
<textarea [(ngModel)]="meetingForm.agenda" rows="3" placeholder="Meeting agenda..."></textarea>
<label>Agenda Items</label>
<div class="agenda-list">
@for (item of meetingForm.agendaItems; track $index) {
<div class="agenda-item-row">
<span class="agenda-num">{{ $index + 1 }}.</span>
<input
type="text"
[name]="'agendaItem' + $index"
[value]="meetingForm.agendaItems[$index]"
(input)="meetingForm.agendaItems[$index] = $any($event.target).value"
placeholder="e.g. Review financials, Discuss elections..."
/>
@if (meetingForm.agendaItems.length > 1) {
<button type="button" class="btn-remove-agenda" title="Remove" (click)="removeAgendaItem($index)">
<mat-icon>remove_circle_outline</mat-icon>
</button>
}
</div>
}
</div>
<button type="button" class="btn-add-agenda" (click)="addAgendaItem()">
<mat-icon>add_circle_outline</mat-icon> Add Item
</button>
</div>
</div>
@@ -1244,6 +1244,99 @@ $font:
gap: 12px;
}
// ── Agenda Items ──────────────────────────────────────────────────
.agenda-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.agenda-item-row {
display: flex;
align-items: center;
gap: 8px;
.agenda-num {
font-size: 12px;
color: #94a3b8;
min-width: 18px;
text-align: right;
flex-shrink: 0;
}
input {
flex: 1;
}
}
.btn-remove-agenda {
background: none;
border: none;
cursor: pointer;
color: #ef4444;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
flex-shrink: 0;
transition: background 0.15s;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
}
&:hover {
background: #fef2f2;
}
}
.btn-add-agenda {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px dashed #cbd5e1;
border-radius: 8px;
cursor: pointer;
color: #6366f1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
transition:
border-color 0.15s,
background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
border-color: #6366f1;
background: #eef2ff;
}
}
.meeting-agenda-preview {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
margin-top: 2px;
.meta-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
@media (width <= 600px) {
.committee-page {
padding: 16px;
@@ -65,6 +65,8 @@ export class CommitteeBoardComponent implements OnInit {
savingMeeting = false;
submittedMeeting = false;
meetingForm: any = this.emptyMeetingForm();
meetingErrors: Record<string, string> = {};
readonly todayDate: string = new Date().toISOString().split('T')[0];
ngOnInit(): void {
this.load();
@@ -314,21 +316,33 @@ export class CommitteeBoardComponent implements OnInit {
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.service.getMeeting(meeting.id).subscribe({
next: (fresh) => {
this.meetingForm = this.buildMeetingFormObj(fresh);
this.showMeetingForm = true;
},
error: () => {
this.meetingForm = this.buildMeetingFormObj(meeting);
this.showMeetingForm = true;
}
});
}
private buildMeetingFormObj(m: any): any {
return {
title: m.title,
meetingDate: m.meetingDate,
meetingTime: m.meetingTime ?? '',
mode: m.mode ?? 'OFFLINE',
agendaItems: m.agendaItems?.length > 0 ? [...m.agendaItems] : [''],
platform: m.platform ?? '',
meetingLink: m.meetingLink ?? '',
meetingIdRef: m.meetingIdRef ?? '',
meetingPassword: m.meetingPassword ?? '',
venue: m.venue ?? '',
address: m.address ?? '',
city: m.city ?? ''
};
this.showMeetingForm = true;
}
closeMeetingForm(): void {
@@ -337,11 +351,47 @@ export class CommitteeBoardComponent implements OnInit {
this.currentMeetingCommitteeId = null;
this.meetingForm = this.emptyMeetingForm();
this.submittedMeeting = false;
this.meetingErrors = {};
}
private validateMeetingForm(): boolean {
const errors: Record<string, string> = {};
const title = this.meetingForm.title?.trim() ?? '';
if (!title) {
errors['title'] = 'Meeting title is required';
} else if (title.length > 200) {
errors['title'] = 'Meeting title must not exceed 200 characters';
}
if (!this.meetingForm.meetingDate) {
errors['meetingDate'] = 'Meeting date is required';
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(this.meetingForm.meetingDate);
if (selected < today) {
errors['meetingDate'] = 'Meeting date cannot be in the past';
}
}
if (!this.meetingForm.mode) {
errors['mode'] = 'Please select a meeting mode';
}
if (this.meetingForm.mode === 'ONLINE' && this.meetingForm.meetingLink?.trim()) {
const link: string = this.meetingForm.meetingLink.trim();
if (!link.startsWith('http://') && !link.startsWith('https://')) {
errors['meetingLink'] = 'Meeting link must start with http:// or https://';
}
}
this.meetingErrors = errors;
return Object.keys(errors).length === 0;
}
saveMeeting(): void {
this.submittedMeeting = true;
if (!this.meetingForm.title?.trim() || !this.meetingForm.meetingDate) return;
if (!this.validateMeetingForm()) return;
this.savingMeeting = true;
const payload: any = {
@@ -349,7 +399,7 @@ export class CommitteeBoardComponent implements OnInit {
meetingDate: this.meetingForm.meetingDate,
meetingTime: this.meetingForm.meetingTime || null,
mode: this.meetingForm.mode,
agenda: this.meetingForm.agenda?.trim() || null
agendaItems: (this.meetingForm.agendaItems as string[]).filter((a) => a?.trim())
};
if (this.meetingForm.mode === 'ONLINE') {
@@ -421,17 +471,154 @@ export class CommitteeBoardComponent implements OnInit {
return mode === 'ONLINE' ? 'video_call' : 'location_on';
}
copyMeetingDetails(meeting: any, event: MouseEvent): void {
event.stopPropagation();
const lines: string[] = [];
lines.push(`📅 Committee Meeting: ${meeting.title}`);
lines.push(`Date: ${meeting.meetingDate}`);
if (meeting.meetingTime) lines.push(`Time: ${meeting.meetingTime}`);
lines.push(`Mode: ${meeting.mode}`);
if (meeting.mode === 'ONLINE') {
if (meeting.platform) lines.push(`Platform: ${meeting.platform}`);
if (meeting.meetingLink) lines.push(`Join Link: ${meeting.meetingLink}`);
if (meeting.meetingIdRef) lines.push(`Meeting ID: ${meeting.meetingIdRef}`);
if (meeting.meetingPassword) lines.push(`Password: ${meeting.meetingPassword}`);
} else {
if (meeting.venue) lines.push(`Venue: ${meeting.venue}`);
if (meeting.address) lines.push(`Address: ${meeting.address}`);
if (meeting.city) lines.push(`City: ${meeting.city}`);
}
if (meeting.agendaItems?.length > 0) {
lines.push('Agenda:');
(meeting.agendaItems as string[]).forEach((item, i) => lines.push(` ${i + 1}. ${item}`));
}
navigator.clipboard.writeText(lines.join('\n')).then(() => {
this.snackBar.open('Meeting details copied!', 'Close', { duration: 2500 });
});
}
downloadMeetingPdf(meeting: any, committee: any, event: MouseEvent): void {
event.stopPropagation();
this.service.getMeeting(meeting.id).subscribe({
next: (fresh) => this.openPrintWindow(fresh, committee),
error: () => this.openPrintWindow(meeting, committee)
});
}
private openPrintWindow(meeting: any, committee: any): void {
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) {
this.snackBar.open('Popup blocked. Please allow popups.', 'Close', { duration: 3500 });
return;
}
const fmt = (d: string) => {
if (!d) return '—';
const dt = new Date(d);
return isNaN(dt.getTime())
? d
: dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
};
const agendaRows = ((meeting.agendaItems as string[]) || [])
.filter((a) => a?.trim())
.map(
(a, i) =>
`<tr><td style="width:28px;vertical-align:top;padding:4px 0;"><span style="background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:inline-block;text-align:center;line-height:20px;font-size:11px;font-weight:700;">${i + 1}</span></td><td style="padding:4px 0 4px 10px;font-size:13px;color:#1c1917;">${a}</td></tr>`
)
.join('');
const row = (icon: string, label: string, value: string) =>
`<tr><td style="padding:9px 12px;font-size:12px;color:#64748b;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;border-bottom:1px solid #e2e8f0;width:130px;">${icon} ${label}</td><td style="padding:9px 12px;font-size:13px;color:#334155;border-bottom:1px solid #e2e8f0;">${value || '—'}</td></tr>`;
const isOnline = meeting.mode === 'ONLINE';
let locationRows = '';
if (isOnline) {
if (meeting.platform) locationRows += row('📹', 'Platform', meeting.platform);
if (meeting.meetingLink)
locationRows += row('🔗', 'Join Link', `<a href="${meeting.meetingLink}">${meeting.meetingLink}</a>`);
if (meeting.meetingIdRef) locationRows += row('🔑', 'Meeting ID', meeting.meetingIdRef);
if (meeting.meetingPassword) locationRows += row('🔒', 'Password', meeting.meetingPassword);
} else {
if (meeting.venue) locationRows += row('🏛', 'Venue', meeting.venue);
if (meeting.address) locationRows += row('📍', 'Address', meeting.address);
if (meeting.city) locationRows += row('🌍', 'City', meeting.city);
}
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Meeting - ${meeting.title}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #f0f4ff; padding: 24px; }
.card { max-width: 720px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,.10); }
.header { background: #1e40af; padding: 22px 28px; }
.header-eyebrow { font-size: 11px; font-weight: 700; color: rgba(255,255,255,.6); letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 4px; }
.header-title { font-size: 22px; font-weight: 800; color: #fff; }
.header-sub { margin-top: 6px; font-size: 13px; color: rgba(255,255,255,.75); }
.body { padding: 24px 28px; }
.section-label { font-size: 11px; font-weight: 700; color: #94a3b8; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 10px; }
.details-table { width: 100%; border-collapse: collapse; background: #f8faff; border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; margin-bottom: 20px; }
.agenda-box { background: #fefce8; border: 1px solid #fde68a; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; }
.agenda-label { font-size: 13px; font-weight: 700; color: #92400e; margin-bottom: 10px; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 12px 28px; display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
@media print { body { background: #fff; padding: 0; } .card { box-shadow: none; border-radius: 0; } .no-print { display: none !important; } }
</style></head><body>
<div class="no-print" style="max-width:720px;margin:0 auto 12px;text-align:right;">
<button onclick="window.print()" style="background:#1e40af;color:#fff;border:none;padding:8px 20px;border-radius:6px;font-size:13px;cursor:pointer;">🖨 Print / Save as PDF</button>
</div>
<div class="card">
<div class="header">
<div class="header-eyebrow">CBSnexora · Committee Meeting</div>
<div class="header-title">📅 ${meeting.title}</div>
${committee?.name ? `<div class="header-sub">👥 ${committee.name}</div>` : ''}
</div>
<div class="body">
<div class="section-label">Meeting Details</div>
<table class="details-table">
<tbody>
${committee?.name ? row('👥', 'Committee', committee.name) : ''}
${row('📅', 'Date', fmt(meeting.meetingDate))}
${row('⏰', 'Time', meeting.meetingTime || '—')}
${row('🔗', 'Mode', isOnline ? '🌐 Online' : '📍 Offline')}
${row('📊', 'Status', meeting.status || '—')}
${locationRows}
</tbody>
</table>
${agendaRows ? `<div class="agenda-box"><div class="agenda-label">📋 AGENDA</div><table style="width:100%;border-collapse:collapse;">${agendaRows}</table></div>` : ''}
</div>
<div class="footer">
<span>CBSnexora — Committee Meeting Record</span>
<span>Generated: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
</div>
</body></html>`;
w.document.write(html);
w.document.close();
w.focus();
}
private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] };
}
addAgendaItem(): void {
this.meetingForm.agendaItems.push('');
}
removeAgendaItem(index: number): void {
if (this.meetingForm.agendaItems.length > 1) {
this.meetingForm.agendaItems.splice(index, 1);
}
}
private emptyMeetingForm(): any {
return {
title: '',
meetingDate: '',
meetingTime: '',
mode: 'OFFLINE',
agenda: '',
agendaItems: [''],
platform: '',
meetingLink: '',
meetingIdRef: '',