feat(election): dynamic designations, vote remark with voice input, and modal vote panel

- Designations loaded dynamically from DirectorDesignation m_code via new API endpoint
- Director board dropdown and committee board validation now use DB-driven designation list
- Mandatory designation check in committee creation driven by is_mandatory flag from m_code_value
- Vote summary panel converted from side drawer to centered modal with backdrop blur
- Voting page: remark textarea shown on Approve/Reject with Web Speech API voice input
- Voice input supports Hindi (hi-IN) and English (en-IN) with language toggle
- NgZone fix: speech callbacks wrapped in zone.run() so Angular change detection fires correctly
- Speech recognition uses continuous=true so user can dictate full remarks before stopping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-07 18:24:18 +05:30
co-authored by Claude Sonnet 4.6
parent 8a1b7d5d6c
commit 93f4b9b03f
10 changed files with 2663 additions and 767 deletions
@@ -7,6 +7,11 @@
-->
<div class="committee-page">
<!-- Ambient background glow elements -->
<div class="ambient-glow blob-1"></div>
<div class="ambient-glow blob-2"></div>
<div class="ambient-glow blob-3"></div>
<!-- Header -->
<div class="page-header">
<div class="header-left">
@@ -149,11 +154,22 @@
<div class="meeting-list">
@for (m of getMeetings(c.id); track m.id) {
<div class="meeting-row">
<div class="meeting-mode-icon" [title]="m.mode">
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon>
<!-- Top Header Row -->
<div class="meeting-header-row">
<div class="meeting-mode-icon" [title]="m.mode">
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon>
</div>
<span class="meeting-title" [title]="m.title">{{ m.title }}</span>
<span
class="meeting-status-chip"
[style.background]="meetingStatusColor(m.status) + '18'"
[style.color]="meetingStatusColor(m.status)"
>{{ m.status }}</span
>
</div>
<div class="meeting-info">
<span class="meeting-title">{{ m.title }}</span>
<!-- Details Area -->
<div class="meeting-details-area">
<div class="meeting-meta-row">
<mat-icon class="meta-icon">calendar_today</mat-icon>
<span>{{ m.meetingDate | date: 'dd MMM yyyy' }}</span>
@@ -162,13 +178,6 @@
<mat-icon class="meta-icon">schedule</mat-icon>
<span>{{ m.meetingTime }}</span>
}
<span class="meta-sep">·</span>
<span
class="meeting-status-chip"
[style.background]="meetingStatusColor(m.status) + '18'"
[style.color]="meetingStatusColor(m.status)"
>{{ m.status }}</span
>
</div>
@if (m.mode === 'ONLINE' && m.platform) {
<div class="meeting-platform-row">
@@ -201,13 +210,31 @@
</div>
}
</div>
<div class="meeting-actions">
<!-- Actions Row -->
<div class="meeting-actions-row">
<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 (m.status === 'COMPLETED') {
<button
class="btn-icon purple"
title="Send Voting Emails"
(click)="sendVotingEmailsDirect(m, $event)"
>
<mat-icon>forward_to_inbox</mat-icon>
</button>
<button
class="btn-icon purple"
title="View Votes & Resolutions"
(click)="openVotePanel(m, $event)"
>
<mat-icon>how_to_vote</mat-icon>
</button>
}
@if (c.status === 'ACTIVE') {
@if (m.status === 'SCHEDULED') {
<button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)">
@@ -525,3 +552,126 @@
</div>
</div>
}
<!-- ── Vote Summary & Resolution Modal ─────────────────────────── -->
@if (showVotePanel) {
<div class="modal-backdrop" (click)="closeVotePanel()"></div>
<div class="vote-modal">
<!-- Modal header -->
<div class="vm-header">
<div class="vm-header-left">
<mat-icon class="vm-icon">how_to_vote</mat-icon>
<div>
<div class="vm-title">Vote Summary &amp; Resolutions</div>
@if (votePanelMeeting) {
<div class="vm-subtitle">
{{ votePanelMeeting.title }} · {{ votePanelMeeting.meetingDate | date: 'dd MMM yyyy' }}
</div>
}
</div>
</div>
<div class="vm-header-right">
<button
class="btn-resend"
(click)="resendVotingEmails($event)"
[disabled]="resendingVoteEmails"
title="Resend voting emails"
>
<mat-icon>{{ resendingVoteEmails ? 'hourglass_empty' : 'forward_to_inbox' }}</mat-icon>
{{ resendingVoteEmails ? 'Sending...' : 'Resend Emails' }}
</button>
<button class="vm-close" (click)="closeVotePanel()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
<!-- Modal body -->
<div class="vm-body">
@if (voteSummaryLoading) {
<div class="loading-row"><mat-icon class="spin">autorenew</mat-icon> Loading votes…</div>
} @else if (voteSummary.length === 0) {
<div class="empty-votes">
<mat-icon>inbox</mat-icon>
<p>No agenda items found for this meeting.</p>
</div>
} @else {
@for (item of voteSummary; track item.agendaId) {
<div class="vote-item-card">
<div class="vi-header">
<span class="vi-num">{{ $index + 1 }}</span>
<span class="vi-label">{{ item.agendaItem }}</span>
</div>
<div class="vote-counts">
<div class="vc approved">
<mat-icon>check_circle</mat-icon><span>{{ item.approvedCount }} Approved</span>
</div>
<div class="vc rejected">
<mat-icon>cancel</mat-icon><span>{{ item.rejectedCount }} Rejected</span>
</div>
<div class="vc abstain">
<mat-icon>pause_circle</mat-icon><span>{{ item.abstainCount }} Abstain</span>
</div>
<div class="vc pending">
<mat-icon>pending</mat-icon><span>{{ item.pendingCount }} Pending</span>
</div>
</div>
<div class="resolution-row">
<label class="res-label">Final Resolution:</label>
<div class="res-buttons">
<button
class="btn-res approve"
[class.active]="resolutionForm[item.agendaId]?.resolution === 'APPROVED'"
(click)="resolutionForm[item.agendaId].resolution = 'APPROVED'"
type="button"
>
<mat-icon>check_circle</mat-icon> Approved
</button>
<button
class="btn-res reject"
[class.active]="resolutionForm[item.agendaId]?.resolution === 'REJECTED'"
(click)="resolutionForm[item.agendaId].resolution = 'REJECTED'"
type="button"
>
<mat-icon>cancel</mat-icon> Rejected
</button>
<button
class="btn-res defer"
[class.active]="resolutionForm[item.agendaId]?.resolution === 'DEFERRED'"
(click)="resolutionForm[item.agendaId].resolution = 'DEFERRED'"
type="button"
>
<mat-icon>pause_circle</mat-icon> Deferred
</button>
</div>
</div>
<div class="notes-row">
<textarea
class="notes-input"
placeholder="Discussion notes (optional)…"
rows="2"
[(ngModel)]="resolutionForm[item.agendaId].discussionNotes"
></textarea>
</div>
</div>
}
}
</div>
<!-- Modal footer -->
<div class="vm-footer">
<button class="btn-cancel" (click)="closeVotePanel()">Cancel</button>
<button
class="btn-save purple-btn"
(click)="saveResolutions()"
[disabled]="savingResolutions || voteSummaryLoading"
>
<mat-icon>{{ savingResolutions ? 'hourglass_empty' : 'send' }}</mat-icon>
{{ savingResolutions ? 'Saving...' : 'Save & Send Minutes' }}
</button>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -68,8 +68,23 @@ export class CommitteeBoardComponent implements OnInit {
meetingErrors: Record<string, string> = {};
readonly todayDate: string = new Date().toISOString().split('T')[0];
resendingVoteEmails = false;
mandatoryDesignations: string[] = [];
// vote summary / resolution drawer state
showVotePanel = false;
votePanelMeeting: any = null;
voteSummary: any[] = [];
voteSummaryLoading = false;
resolutionForm: Record<number, { resolution: string; discussionNotes: string }> = {};
savingResolutions = false;
ngOnInit(): void {
this.load();
this.service.getDesignations().subscribe((data: any[]) => {
this.mandatoryDesignations = (data ?? []).filter((d: any) => d.mandatory).map((d: any) => d.name);
});
}
load(): void {
@@ -208,6 +223,17 @@ export class CommitteeBoardComponent implements OnInit {
this.submitted = true;
if (!this.form.name?.trim()) return;
// Mandatory designations (e.g. Chairman, Vice Chairman) required for new committee
if (this.editingId == null && this.mandatoryDesignations.length > 0) {
const selected = this.availableDirectors.filter((d: any) => this.form.selectedIds.includes(d.id));
const presentDesignations = new Set(selected.map((d: any) => d.designation));
const missing = this.mandatoryDesignations.filter((des) => !presentDesignations.has(des));
if (missing.length > 0) {
this.snackBar.open(`Committee must include an active ${missing.join(' and ')}.`, 'Close', { duration: 5000 });
return;
}
}
this.saving = true;
const payload = {
name: this.form.name.trim(),
@@ -598,6 +624,112 @@ export class CommitteeBoardComponent implements OnInit {
w.focus();
}
sendVotingEmailsDirect(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.service.resendVotingEmails(meeting.id).subscribe({
next: () => this.snackBar.open('Voting emails sent to all members!', 'Close', { duration: 4000 }),
error: () => this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 })
});
}
resendVotingEmails(event: MouseEvent): void {
event.stopPropagation();
if (this.resendingVoteEmails || !this.votePanelMeeting) return;
this.resendingVoteEmails = true;
this.service.resendVotingEmails(this.votePanelMeeting.id).subscribe({
next: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Voting emails sent to all committee members.', 'Close', { duration: 4000 });
},
error: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 });
}
});
}
openVotePanel(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.votePanelMeeting = meeting;
this.showVotePanel = true;
this.voteSummary = [];
this.resolutionForm = {};
this.voteSummaryLoading = true;
this.service.getVoteSummary(meeting.id).subscribe({
next: (data) => {
this.voteSummary = data ?? [];
this.resolutionForm = {};
for (const item of this.voteSummary) {
this.resolutionForm[item.agendaId] = {
resolution: item.resolution ?? '',
discussionNotes: item.discussionNotes ?? ''
};
}
this.voteSummaryLoading = false;
},
error: () => {
this.voteSummaryLoading = false;
this.snackBar.open('Failed to load vote summary.', 'Close', { duration: 3000 });
}
});
}
closeVotePanel(): void {
this.showVotePanel = false;
this.votePanelMeeting = null;
this.voteSummary = [];
}
saveResolutions(): void {
if (this.savingResolutions) return;
const meetingId = this.votePanelMeeting?.id;
if (!meetingId) return;
const payload = Object.entries(this.resolutionForm)
.filter(
([
,
v
]) => v.resolution
)
.map(
([
agendaId,
v
]) => ({
agendaId: Number(agendaId),
resolution: v.resolution,
discussionNotes: v.discussionNotes
})
);
if (!payload.length) {
this.snackBar.open('Please set at least one resolution.', 'Close', { duration: 3000 });
return;
}
this.savingResolutions = true;
this.service.saveResolutions(meetingId, payload).subscribe({
next: () => {
this.savingResolutions = false;
this.snackBar.open('Resolutions saved and meeting minutes sent to all members.', 'Close', { duration: 4000 });
this.closeVotePanel();
},
error: () => {
this.savingResolutions = false;
this.snackBar.open('Failed to save resolutions.', 'Close', { duration: 3000 });
}
});
}
resolutionIcon(r: string): string {
return r === 'APPROVED' ? 'check_circle' : r === 'REJECTED' ? 'cancel' : 'pause_circle';
}
resolutionColor(r: string): string {
return r === 'APPROVED' ? '#16a34a' : r === 'REJECTED' ? '#dc2626' : '#d97706';
}
private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] };
}
@@ -96,19 +96,18 @@ export class DirectorBoardComponent implements OnInit {
readonly today: Date = new Date();
designationOptions = [
'Chairman',
'Vice Chairman',
'Secretary',
'Treasurer',
'Director',
'Managing Director',
'Chief Executive Officer'
];
designationOptions: string[] = [];
ngOnInit(): void {
this.load();
this.loadPending();
this.loadDesignations();
}
loadDesignations(): void {
this.service.getDesignations().subscribe((data: any[]) => {
this.designationOptions = (data ?? []).map((d: any) => d.name);
});
}
loadPending(): void {
@@ -0,0 +1,212 @@
<!--
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/.
-->
<!-- Public voting page — no shell/navbar — single viewport height -->
<div class="vote-page">
<!-- Loading -->
@if (loading) {
<div class="state-center">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading meeting details…</p>
</div>
}
<!-- Error / invalid token -->
@if (!loading && error && !page) {
<div class="state-center">
<div class="state-card error-card">
<mat-icon class="state-icon error">link_off</mat-icon>
<h2>Link Invalid or Expired</h2>
<p>{{ error }}</p>
<p class="hint">Please contact your committee administrator if you believe this is a mistake.</p>
</div>
</div>
}
<!-- Voting closed / already submitted -->
@if (!loading && page && (page.votingClosed || submitted)) {
<div class="state-center">
<div class="state-card success-card">
<mat-icon class="state-icon success">check_circle</mat-icon>
<h2>{{ submitted ? 'Vote Submitted!' : 'Voting Closed' }}</h2>
@if (submitted) {
<p>
Thank you, <strong>{{ page.memberName }}</strong
>. Your votes have been recorded.
</p>
} @else {
<p>You have already voted or the voting period has ended.</p>
}
<div class="summary-box">
<div class="summary-header">{{ page.meetingTitle }}</div>
<div class="summary-meta">{{ page.committeeName }} &nbsp;·&nbsp; {{ page.meetingDate }}</div>
@for (item of page.agendaItems; track item.agendaId) {
<div class="summary-row">
<span class="item-num">{{ $index + 1 }}</span>
<span class="item-text">{{ item.agendaItem }}</span>
@if (selections[item.agendaId]) {
<span class="vote-badge" [class]="'badge-' + selections[item.agendaId].toLowerCase()">
{{ voteLabel(selections[item.agendaId]) }}
</span>
}
</div>
}
</div>
</div>
</div>
}
<!-- Active voting form — full viewport single page -->
@if (!loading && page && !page.votingClosed && !submitted) {
<!-- Fixed header bar -->
<div class="vote-header">
<div class="vh-left">
<div class="vh-brand">CBSnexora · Committee Meeting</div>
<div class="vh-title">🗳️ Cast Your Vote</div>
<div class="vh-meta">{{ page.committeeName }} &nbsp;·&nbsp; {{ page.meetingDate }}</div>
</div>
<div class="vh-member">
<div class="vh-member-label">Voting as</div>
<div class="vh-member-name">{{ page.memberName }}</div>
</div>
</div>
<!-- Scrollable agenda area -->
<div class="vote-scroll">
<div class="scroll-inner">
<p class="subtitle">
Please review each agenda item from <strong>{{ page.meetingTitle }}</strong> and cast your vote.
</p>
@if (error) {
<div class="alert-error">{{ error }}</div>
}
<div class="agenda-list">
@for (item of page.agendaItems; track item.agendaId) {
<div class="agenda-item" [class.voted]="!!selections[item.agendaId]">
<div class="item-label">
<span class="item-num">{{ $index + 1 }}</span>
<span class="item-text">{{ item.agendaItem }}</span>
@if (selections[item.agendaId]) {
<span class="vote-badge" [class]="'badge-' + selections[item.agendaId].toLowerCase()">
{{ voteLabel(selections[item.agendaId]) }}
</span>
}
</div>
<div class="vote-buttons">
<button
class="btn-vote approve"
[class.active]="selections[item.agendaId] === 'APPROVED'"
(click)="select(item.agendaId, 'APPROVED')"
type="button"
>
✅ Approve
</button>
<button
class="btn-vote reject"
[class.active]="selections[item.agendaId] === 'REJECTED'"
(click)="select(item.agendaId, 'REJECTED')"
type="button"
>
❌ Reject
</button>
<button
class="btn-vote abstain"
[class.active]="selections[item.agendaId] === 'ABSTAIN'"
(click)="select(item.agendaId, 'ABSTAIN')"
type="button"
>
— Abstain
</button>
</div>
<!-- Remark row — shown only for Approve / Reject -->
@if (selections[item.agendaId] === 'APPROVED' || selections[item.agendaId] === 'REJECTED') {
<div class="remark-row">
<textarea
class="remark-input"
[placeholder]="
selections[item.agendaId] === 'REJECTED'
? 'कारण / Reason for rejection…'
: 'टिप्पणी / Add your remark…'
"
[(ngModel)]="remarks[item.agendaId]"
rows="2"
></textarea>
@if (speechSupported) {
<div class="remark-controls">
<button
class="btn-mic"
[class.active]="recording === item.agendaId"
(click)="toggleRecording(item.agendaId)"
type="button"
[title]="recording === item.agendaId ? 'Stop recording' : 'Start voice input'"
>
<mat-icon>{{ recording === item.agendaId ? 'stop_circle' : 'mic' }}</mat-icon>
<span class="mic-label">{{ recording === item.agendaId ? 'Stop' : 'Speak' }}</span>
</button>
<div class="lang-toggle">
<button
class="btn-lang"
[class.active]="recordingLang === 'hi-IN'"
(click)="switchLang('hi-IN')"
type="button"
title="Hindi"
>
HI
</button>
<button
class="btn-lang"
[class.active]="recordingLang === 'en-IN'"
(click)="switchLang('en-IN')"
type="button"
title="English"
>
EN
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Fixed bottom bar — submit always visible -->
<div class="vote-bottom-bar">
<div class="bottom-inner">
<div class="progress-text">
@if (allVoted) {
<mat-icon class="prog-icon done">check_circle</mat-icon>
<span>All {{ page.agendaItems.length }} items voted — ready to submit</span>
} @else {
<mat-icon class="prog-icon">pending</mat-icon>
<span>{{ votedCount() }} of {{ page.agendaItems.length }} items voted</span>
}
</div>
<button class="btn-submit" [disabled]="!allVoted || submitting" (click)="submit()">
@if (submitting) {
<mat-spinner diameter="18" color="accent"></mat-spinner>
Submitting…
} @else {
Submit All Votes
}
</button>
</div>
<div class="bottom-footer">
<span>CBSnexora — Automated Voting System</span>
<span>Each member can vote once. Link valid for 7 days.</span>
</div>
</div>
}
</div>
@@ -0,0 +1,564 @@
/**
* 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/.
*/
// ── Single viewport page ──────────────────────────────────────────────────────
.vote-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f5f6fa;
font-family: Arial, Helvetica, sans-serif;
font-size: 13px;
}
// ── Header ────────────────────────────────────────────────────────────────────
.vote-header {
flex-shrink: 0;
background: #7c3aed;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-shadow: 0 1px 8px rgb(91 33 182 / 35%);
.vh-left {
display: flex;
flex-direction: column;
gap: 1px;
}
.vh-brand {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.8px;
text-transform: uppercase;
color: rgb(255 255 255 / 50%);
}
.vh-title {
font-size: 17px;
font-weight: 800;
color: #fff;
line-height: 1.2;
}
.vh-meta {
font-size: 11px;
color: rgb(255 255 255 / 70%);
}
.vh-member {
text-align: right;
.vh-member-label {
font-size: 9px;
color: rgb(255 255 255 / 45%);
text-transform: uppercase;
letter-spacing: 1px;
}
.vh-member-name {
font-size: 13px;
font-weight: 700;
color: #fff;
}
}
}
// ── Scrollable agenda ─────────────────────────────────────────────────────────
.vote-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f5f6fa;
}
&::-webkit-scrollbar-thumb {
background: #c4b5fd;
border-radius: 2px;
}
}
.scroll-inner {
max-width: 860px;
margin: 0 auto;
padding: 14px 24px 10px;
.subtitle {
margin: 0 0 12px;
font-size: 12px;
color: #64748b;
}
}
// ── Agenda list ───────────────────────────────────────────────────────────────
.agenda-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agenda-item {
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
transition: border-color 0.15s;
&.voted {
border-color: #a78bfa;
}
.item-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-num {
background: #7c3aed;
color: #fff;
border-radius: 50%;
width: 20px;
height: 20px;
min-width: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.item-text {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #1e293b;
line-height: 1.3;
}
}
.vote-buttons {
display: flex;
gap: 6px;
padding-left: 28px;
}
.btn-vote {
flex: 1;
padding: 6px 8px;
border-radius: 6px;
border: 1.5px solid #e2e8f0;
font-size: 12px;
font-weight: 600;
cursor: pointer;
background: #f8fafc;
color: #64748b;
transition: all 0.12s ease;
white-space: nowrap;
&:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
&.approve.active {
background: #dcfce7;
border-color: #16a34a;
color: #15803d;
}
&.reject.active {
background: #fee2e2;
border-color: #dc2626;
color: #b91c1c;
}
&.abstain.active {
background: #fef3c7;
border-color: #d97706;
color: #b45309;
}
}
// ── Remark row ────────────────────────────────────────────────────────────────
.remark-row {
display: flex;
gap: 8px;
padding-left: 28px;
padding-top: 8px;
align-items: flex-start;
}
.remark-input {
flex: 1;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
font-family: inherit;
color: #334155;
resize: none;
line-height: 1.4;
transition: border-color 0.15s;
background: #fafbff;
&:focus {
outline: none;
border-color: #a78bfa;
background: #fff;
}
&::placeholder {
color: #94a3b8;
font-style: italic;
}
}
.remark-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.btn-mic {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: #f1f5f9;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 5px 8px;
cursor: pointer;
color: #64748b;
transition: all 0.15s;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.mic-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
}
&:hover {
background: #e2e8f0;
}
&.active {
background: #fee2e2;
border-color: #f87171;
color: #dc2626;
animation: pulse-mic 1.2s ease-in-out infinite;
}
}
@keyframes pulse-mic {
0%,
100% {
box-shadow: 0 0 0 0 rgb(220 38 38 / 30%);
}
50% {
box-shadow: 0 0 0 5px rgb(220 38 38 / 0%);
}
}
.lang-toggle {
display: flex;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.btn-lang {
background: #f8fafc;
border: none;
padding: 3px 7px;
font-size: 9px;
font-weight: 700;
color: #94a3b8;
cursor: pointer;
transition: all 0.12s;
letter-spacing: 0.5px;
&:first-child {
border-right: 1px solid #e2e8f0;
}
&.active {
background: #7c3aed;
color: #fff;
}
}
// ── Bottom bar ────────────────────────────────────────────────────────────────
.vote-bottom-bar {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -2px 8px rgb(0 0 0 / 5%);
}
.bottom-inner {
max-width: 860px;
margin: 0 auto;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.progress-text {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #64748b;
.prog-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
&.done {
color: #16a34a;
}
}
}
.btn-submit {
background: #7c3aed;
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 28px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.12s;
flex-shrink: 0;
&:hover:not(:disabled) {
background: #6d28d9;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.bottom-footer {
max-width: 860px;
margin: 0 auto;
padding: 5px 24px 8px;
display: flex;
justify-content: space-between;
font-size: 10px;
color: #b0bac9;
border-top: 1px solid #f1f5f9;
}
// ── State pages ───────────────────────────────────────────────────────────────
.state-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 20px;
}
.state-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgb(0 0 0 / 8%);
padding: 36px 32px;
text-align: center;
width: 100%;
max-width: 440px;
.state-icon {
font-size: 48px;
width: 48px;
height: 48px;
&.success {
color: #16a34a;
}
&.error {
color: #dc2626;
}
}
h2 {
margin: 12px 0 6px;
font-size: 20px;
color: #1e293b;
}
p {
font-size: 13px;
color: #64748b;
margin: 0 0 4px;
}
.hint {
font-size: 11px;
color: #94a3b8;
margin-top: 6px;
}
}
// ── Summary box ───────────────────────────────────────────────────────────────
.summary-box {
margin-top: 16px;
background: #f8faff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
text-align: left;
max-height: 45vh;
overflow-y: auto;
.summary-header {
padding: 10px 14px 2px;
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.summary-meta {
padding: 0 14px 8px;
font-size: 11px;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
}
.summary-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
.item-num {
background: #7c3aed;
color: #fff;
border-radius: 50%;
width: 18px;
height: 18px;
min-width: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.item-text {
flex: 1;
font-size: 12px;
color: #334155;
}
}
}
// ── Badges ────────────────────────────────────────────────────────────────────
.vote-badge {
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
&.badge-approved {
background: #dcfce7;
color: #15803d;
}
&.badge-rejected {
background: #fee2e2;
color: #b91c1c;
}
&.badge-abstain {
background: #fef3c7;
color: #b45309;
}
}
// ── Alert ─────────────────────────────────────────────────────────────────────
.alert-error {
background: #fee2e2;
color: #b91c1c;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
margin-bottom: 12px;
}
// ── Responsive ────────────────────────────────────────────────────────────────
@media (width <= 640px) {
.vote-header {
padding: 8px 14px;
}
.vote-header .vh-member {
display: none;
}
.scroll-inner,
.bottom-inner,
.bottom-footer {
padding-left: 14px;
padding-right: 14px;
}
.vote-buttons {
padding-left: 0;
flex-wrap: wrap;
}
.remark-row {
padding-left: 0;
}
.btn-submit {
padding: 8px 18px;
}
}
@@ -0,0 +1,209 @@
/**
* 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, OnDestroy, NgZone, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-meeting-vote',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatProgressSpinnerModule
],
templateUrl: './meeting-vote.component.html',
styleUrls: ['./meeting-vote.component.scss']
})
export class MeetingVoteComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private service = inject(ElectionService);
private zone = inject(NgZone);
token = '';
page: any = null;
loading = true;
submitting = false;
submitted = false;
error = '';
// agendaId → 'APPROVED' | 'REJECTED' | 'ABSTAIN'
selections: Record<number, string> = {};
// agendaId → remark text
remarks: Record<number, string> = {};
// Voice recording state
recording: number | null = null; // agendaId currently recording
recordingLang: 'hi-IN' | 'en-IN' = 'hi-IN';
readonly speechSupported = !!(window as any).SpeechRecognition || !!(window as any).webkitSpeechRecognition;
private recognition: any = null;
ngOnInit(): void {
this.token = this.route.snapshot.paramMap.get('token') ?? '';
this.load();
}
ngOnDestroy(): void {
this.stopRecognition();
}
load(): void {
this.loading = true;
this.error = '';
this.service.getVotingPage(this.token).subscribe({
next: (data) => {
this.page = data;
(data.agendaItems ?? []).forEach((item: any) => {
if (item.currentVote) this.selections[item.agendaId] = item.currentVote;
});
this.loading = false;
},
error: () => {
this.error = 'Invalid or expired voting link.';
this.loading = false;
}
});
}
select(agendaId: number, vote: string): void {
this.selections[agendaId] = vote;
// Clear remark when switching to Abstain
if (vote === 'ABSTAIN') {
delete this.remarks[agendaId];
if (this.recording === agendaId) this.stopRecognition();
}
}
// ── Voice recording ────────────────────────────────────────────────────────
toggleRecording(agendaId: number): void {
if (this.recording === agendaId) {
this.stopRecognition();
return;
}
if (this.recording !== null) {
this.stopRecognition();
}
this.startRecognition(agendaId);
}
private startRecognition(agendaId: number): void {
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SR) return;
// Run outside Angular zone — callbacks will call zone.run() themselves
this.zone.runOutsideAngular(() => {
this.recognition = new SR();
this.recognition.continuous = true; // keep listening until Stop is pressed
this.recognition.interimResults = false; // only final results
this.recognition.lang = this.recordingLang;
this.recognition.onresult = (event: any) => {
// Collect only new final results since last event
let newText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
newText += event.results[i][0].transcript + ' ';
}
}
if (newText.trim()) {
this.zone.run(() => {
// Spread to new object so Angular picks up the change
this.remarks = {
...this.remarks,
[agendaId]: ((this.remarks[agendaId] ?? '') + ' ' + newText).trim()
};
});
}
};
this.recognition.onerror = () => {
this.zone.run(() => {
this.recording = null;
});
};
this.recognition.onend = () => {
this.zone.run(() => {
this.recording = null;
});
};
this.recognition.start();
});
this.recording = agendaId;
}
private stopRecognition(): void {
try {
this.recognition?.stop();
} catch (_) {}
this.recognition = null;
this.recording = null;
}
switchLang(lang: 'hi-IN' | 'en-IN'): void {
if (this.recordingLang === lang) return;
this.recordingLang = lang;
// Restart recording in new language if currently active
if (this.recording !== null) {
const active = this.recording;
this.stopRecognition();
setTimeout(() => this.startRecognition(active), 100);
}
}
// ── Submit ─────────────────────────────────────────────────────────────────
get allVoted(): boolean {
if (!this.page?.agendaItems?.length) return false;
return this.page.agendaItems.every((a: any) => !!this.selections[a.agendaId]);
}
submit(): void {
if (!this.allVoted || this.submitting) return;
this.stopRecognition();
this.submitting = true;
const votes = Object.entries(this.selections).map(
([
agendaId,
vote
]) => ({
agendaId: Number(agendaId),
vote,
remark: this.remarks[Number(agendaId)] ?? null
})
);
this.service.submitVotes(this.token, votes).subscribe({
next: () => {
this.submitting = false;
this.submitted = true;
},
error: (err: any) => {
this.submitting = false;
this.error = err?.error?.error ?? 'Failed to submit votes. Please try again.';
}
});
}
votedCount(): number {
if (!this.page?.agendaItems?.length) return 0;
return this.page.agendaItems.filter((a: any) => !!this.selections[a.agendaId]).length;
}
voteLabel(vote: string): string {
return vote === 'APPROVED' ? '✅ Approved' : vote === 'REJECTED' ? '❌ Rejected' : '— Abstain';
}
}
@@ -11,6 +11,12 @@ import { RouterModule, Routes } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
// Public voting page — no shell/auth wrapper, accessible via email link
{
path: 'vote/:token',
loadComponent: () => import('./components/meeting-vote/meeting-vote.component').then((m) => m.MeetingVoteComponent),
data: { title: 'Vote on Agenda' }
},
Route.withShell([
{
path: 'election',
+29
View File
@@ -212,6 +212,10 @@ export class ElectionService {
return this.http.get<any[]>('/election/directors/pending').pipe(catchError(() => of([])));
}
getDesignations(): Observable<any[]> {
return this.http.get<any[]>('/election/directors/designations').pipe(catchError(() => of([])));
}
// ── Committees ─────────────────────────────────────────────────
getCommittees(): Observable<any[]> {
return this.http.get<any[]>('/election/committees').pipe(catchError(() => of([])));
@@ -269,4 +273,29 @@ export class ElectionService {
deleteMeeting(meetingId: number): Observable<any> {
return this.http.delete(`/election/committee-meetings/${meetingId}`);
}
// ── Agenda Voting (public — no auth) ─────────────────────────────
getVotingPage(token: string): Observable<any> {
return this.http.get<any>(`/public/meeting-vote/${token}`);
}
submitVotes(token: string, votes: { agendaId: number; vote: string }[]): Observable<any> {
return this.http.post(`/public/meeting-vote/${token}`, { votes });
}
// ── Admin: vote summary + resolutions ────────────────────────────
resendVotingEmails(meetingId: number): Observable<any> {
return this.http.post(`/election/meetings/${meetingId}/votes/resend`, {});
}
getVoteSummary(meetingId: number): Observable<any[]> {
return this.http.get<any[]>(`/election/meetings/${meetingId}/votes/summary`);
}
saveResolutions(
meetingId: number,
resolutions: { agendaId: number; resolution: string; discussionNotes: string }[]
): Observable<any> {
return this.http.put(`/election/meetings/${meetingId}/votes/resolutions`, resolutions);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260703',
'hash': 'a4423877'
'version': '260707',
'hash': '8a1b7d5d'
},
'allow_switching_backend_instance': true
};