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:
co-authored by
Claude Sonnet 4.6
parent
8a1b7d5d6c
commit
93f4b9b03f
@@ -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 & 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 }} · {{ 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 }} · {{ 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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260703',
|
||||
'hash': 'a4423877'
|
||||
'version': '260707',
|
||||
'hash': '8a1b7d5d'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user