From ed54db2e74c027c7448d5cd1a755f0e43f05943a Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 1 Jul 2026 15:10:48 +0530 Subject: [PATCH] feat(election): manual vote entry, results declaration, remove electronic voting Co-Authored-By: Claude Sonnet 4.6 --- .../candidate-board.component.ts | 7 +- .../election-dashboard.component.html | 29 +- .../election-dashboard.component.scss | 12 +- .../election-dashboard.component.ts | 51 +- .../election-results.component.html | 292 ++++-- .../election-results.component.scss | 920 +++++++++++++++--- .../election-results.component.ts | 148 ++- src/app/election/election-routing.module.ts | 6 - src/app/election/election.service.ts | 14 +- 9 files changed, 1242 insertions(+), 237 deletions(-) diff --git a/src/app/election/components/candidate-board/candidate-board.component.ts b/src/app/election/components/candidate-board/candidate-board.component.ts index 2098f0cc9..98e34fde2 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.ts +++ b/src/app/election/components/candidate-board/candidate-board.component.ts @@ -206,12 +206,7 @@ export class CandidateBoardComponent implements OnInit { } private loadVoteCounts(): void { - this.service.getVoteSummary(this.electionId).subscribe((summary) => { - if (!summary?.breakdown) return; - for (const row of summary.breakdown) { - this.voteCounts[row.candidateId] = row.voteCount; - } - }); + // Vote counts are updated manually via the results page (manual voting mode) } getVoteCount(candidate: any): number { diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.html b/src/app/election/components/election-dashboard/election-dashboard.component.html index 37d6c846c..dbb1765e2 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.html +++ b/src/app/election/components/election-dashboard/election-dashboard.component.html @@ -81,16 +81,6 @@ @if (election.description) {

{{ election.description }}

} - - @if (voteSummaryMap[election.id]) { -
- - how_to_vote - {{ voteSummaryMap[election.id].totalVoters }} members voted - - {{ voteSummaryMap[election.id].totalVotes }} total votes -
- }
@@ -122,11 +112,24 @@ how_to_vote Open Voting } - @if (canVote(election)) { - - ballot Vote Now + @if (election.status === 'VOTING_OPEN') { + + + edit_note Enter Votes } + @if (election.status === 'VOTING_CLOSED') { + + } @if (canViewResults(election)) { bar_chart Results diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.scss b/src/app/election/components/election-dashboard/election-dashboard.component.scss index 64156aac8..753c285d6 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.scss +++ b/src/app/election/components/election-dashboard/election-dashboard.component.scss @@ -236,7 +236,8 @@ .btn-outline, .btn-vote, .btn-results, -.btn-audit { +.btn-audit, +.btn-declare-result { display: inline-flex; align-items: center; gap: 3px; @@ -293,6 +294,15 @@ } } +.btn-declare-result { + background: #744210; + color: #fff; + + &:hover { + background: #5a3309; + } +} + .btn-audit { background: #f1f5f9; color: #475569; diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.ts b/src/app/election/components/election-dashboard/election-dashboard.component.ts index fd9013d33..cdfa98156 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.ts +++ b/src/app/election/components/election-dashboard/election-dashboard.component.ts @@ -86,13 +86,7 @@ export class ElectionDashboardComponent implements OnInit { } private loadVoteSummaries(): void { - for (const e of this.elections) { - if (e.status === 'VOTING_OPEN' || e.status === 'VOTING_CLOSED') { - this.service.getVoteSummary(e.id).subscribe((s) => { - if (s) this.voteSummaryMap[e.id] = s; - }); - } - } + // Electronic voting removed — manual voting only } statusLabel(status: string): string { @@ -212,6 +206,49 @@ export class ElectionDashboardComponent implements OnInit { } }); } + + declareResults(election: any): void { + if (!confirm('Are you sure you want to declare the final results? This cannot be undone.')) return; + this.actionLoading[election.id] = true; + this.service + .declareResults(election.id) + .pipe( + catchError((err) => { + this.snackBar.open(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to declare results', 'Close', { + duration: 4000 + }); + this.actionLoading[election.id] = false; + return of(null); + }) + ) + .subscribe((res) => { + this.actionLoading[election.id] = false; + if (res) { + this.snackBar.open('Results declared successfully!', 'Close', { duration: 3000 }); + this.fetchPage(); + } + }); + } + + closeVoting(election: any): void { + this.actionLoading[election.id] = true; + this.service + .closeVoting(election.id) + .pipe( + catchError((err) => { + this.snackBar.open(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed', 'Close', { duration: 4000 }); + this.actionLoading[election.id] = false; + return of(null); + }) + ) + .subscribe((res) => { + this.actionLoading[election.id] = false; + if (res) { + this.snackBar.open('Voting closed!', 'Close', { duration: 3000 }); + this.fetchPage(); + } + }); + } } function buildPageNumbers(current: number, total: number): (number | string)[] { diff --git a/src/app/election/components/election-results/election-results.component.html b/src/app/election/components/election-results/election-results.component.html index 59ce65bde..bb768c6e8 100644 --- a/src/app/election/components/election-results/election-results.component.html +++ b/src/app/election/components/election-results/election-results.component.html @@ -28,77 +28,247 @@

Loading results...

- } @else if (results.length === 0) { -
- bar_chart -

Results not yet declared

- @if (canDeclare) { -

Voting is closed. Click "Declare Results" to compute and publish the results.

- } @else { -

Results will appear here once the election is complete and results are declared.

- } -
} @else { -
-
- how_to_vote - Election: {{ election?.electionDate | date: 'dd MMM yyyy' }} -
- @if (results[0]?.declaredDate) { -
- gavel - Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy HH:mm' }} -
- } -
- - @for (group of groupByPost(); track group.postName) { -
-
-

{{ group.postName }}

-
- - - @for (result of group.results; track result.id) { - @if (result.rankPosition === 1 && result.result === 'WON') { -
-
emoji_events
-
- Winner -

{{ result.candidateName }}

- {{ result.voteCount | number }} votes -
+ + @if (canEnterManualCounts && candidates.length > 0) { +
+
+
+
how_to_vote
+
+

Enter Manual Vote Counts

+

Physical voting has been completed. Enter the vote count received by each candidate below.

+
+ @if (countsSaved) { + check_circle Counts Saved } - } +
- -
- @for (result of group.results; track result.id) { -
-
- @if (result.result === 'WON') { - emoji_events - } @else { - # {{ result.rankPosition }} + @for (group of groupCandidatesByPost(); track group.postId) { +
+
badge {{ group.postName }}
+ + @if (isUnopposed(group)) { +
+ how_to_vote +
+ {{ group.candidates[0].candidateName }} + No other candidate filed nomination for this post +
+ ELECTED UNOPPOSED +
+ } @else { +
+
+ S.No + Candidate + Votes Received +
+ @for (candidate of group.candidates; track candidate.id) { +
+ #{{ candidate.serialNumber }} + {{ candidate.candidateName }} + + + +
}
-
{{ result.candidateName }}
-
-
- {{ result.voteCount | number }} votes -
-
- {{ result.result }} -
+ } +
+ } + +
+ @if (!countsSaved) { + + } @else if (!editMode) { + + } @else { +
+
+ + + } + + @if (canDeclare) { + + @if (!countsSaved) { + Pehle counts save karein, phir declare karein + } }
} + + @if (results.length === 0 && !canEnterManualCounts) { +
+ bar_chart +

Results not yet declared

+

Results will appear here once the election is complete and results are declared.

+
+ } + + + @if (results.length > 0) { +
+
+ calendar_today Election: {{ election?.electionDate | date: 'dd MMM yyyy' }} +
+ @if (results[0]?.declaredDate) { +
+ gavel Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy, hh:mm a' }} +
+ } +
+ + @for (group of groupByPost(); track group.postName) { +
+
+
+
badge
+

{{ group.postName }}

+
+ + how_to_vote + {{ totalVotes(group.results) | number }} Total Votes + +
+ + @if (winnersOf(group.results).length === 1) { +
+
+
emoji_events
+
+ {{ group.results.length === 1 ? 'ELECTED UNOPPOSED' : 'WINNER' }} +

{{ winnersOf(group.results)[0].candidateName }}

+ @if (group.results.length > 1) { +
+ {{ winnersOf(group.results)[0].voteCount | number }} votes + · + {{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}% + · + Rank #1 +
+ } @else { +
No contest — sole candidate
+ } +
+
+ @if (group.results.length > 1) { +
+
+ {{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}% + share +
+
+ } +
+ } @else if (winnersOf(group.results).length > 1) { +
+ emoji_events + {{ winnersOf(group.results).length }} Elected + Top {{ winnersOf(group.results).length }} seats filled +
+
+ @for (winner of winnersOf(group.results); track winner.id) { +
+
#{{ winner.rankPosition }}
+
+ {{ winner.candidateName }} + {{ winner.voteCount | number }} votes · {{ votePercent(winner.voteCount, group.results) }}% +
+ emoji_events +
+ } +
+ } + +
+
+ @for (result of group.results; track result.id) { +
+ } +
+ @for (result of group.results; track result.id) { +
+
+
+ @if (result.result === 'WON') { + emoji_events + } @else { + {{ result.rankPosition }} + } +
+
+ {{ result.candidateName }} + + {{ result.result }} + +
+
+
+
+
+ {{ result.voteCount | number }} +
+
+ {{ votePercent(result.voteCount, group.results) }}% +
+
+ } +
+
+ } + } }
diff --git a/src/app/election/components/election-results/election-results.component.scss b/src/app/election/components/election-results/election-results.component.scss index 99043791d..8c7dc114f 100644 --- a/src/app/election/components/election-results/election-results.component.scss +++ b/src/app/election/components/election-results/election-results.component.scss @@ -65,184 +65,500 @@ } } -.results-meta { +/* ── Results Meta ── */ +.results-header-meta { display: flex; - gap: 12px; - margin-bottom: 24px; + gap: 10px; + margin-bottom: 28px; flex-wrap: wrap; +} - .meta-chip { - display: inline-flex; - align-items: center; - gap: 6px; - background: #f8fafc; - border: 1px solid #e2e8f0; - padding: 6px 14px; - border-radius: 20px; - font-size: 13px; - color: #4a5568; +.rh-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #f8fafc; + border: 1px solid #e2e8f0; + padding: 7px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: #475569; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + color: #94a3b8; + } + + &.declared { + background: #f0fdf4; + border-color: #bbf7d0; + color: #166534; mat-icon { - font-size: 16px; - height: 16px; - width: 16px; - color: #a0aec0; + color: #16a34a; } } } -.post-result { - background: #fff; - border-radius: 14px; - box-shadow: 0 2px 10px rgb(0 0 0 / 8%); - margin-bottom: 28px; - overflow: hidden; - - .post-result-header { - padding: 16px 24px; - background: #f8fafc; - border-bottom: 1px solid #f0f0f0; - - h3 { - font-size: 18px; - font-weight: 600; - color: #2d3748; - margin: 0; - } - } +/* ── Post Results Wrapper ── */ +.post-results-wrap { + margin-bottom: 24px; } -.winner-banner { +.post-section-header { display: flex; align-items: center; - gap: 20px; - padding: 20px 24px; - background: linear-gradient(135deg, #fefcbf, #fbd38d); - border-bottom: 1px solid #f6e05e; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 2px solid #f1f5f9; - .trophy-icon { - width: 56px; - height: 56px; - background: #744210; - border-radius: 50%; + .psh-left { + display: flex; + align-items: center; + gap: 8px; + } + + .psh-icon { + width: 28px; + height: 28px; + background: #ede9fe; + border-radius: 7px; display: flex; align-items: center; justify-content: center; mat-icon { - color: #fbd38d; - font-size: 28px; + color: #7c3aed; + font-size: 16px; + height: 16px; + width: 16px; } } - .winner-label { + h3 { + font-size: 16px; + font-weight: 700; + color: #1e293b; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .total-votes-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: #f8fafc; + border: 1px solid #e2e8f0; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + color: #475569; + + mat-icon { + font-size: 13px; + height: 13px; + width: 13px; + color: #94a3b8; + } + } +} + +/* ── Winner Hero Card (single winner) ── */ +.winner-hero { + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(135deg, #fffbeb 0%, #fef9c3 60%, #fde68a 100%); + border: 1.5px solid #fcd34d; + border-radius: 14px; + padding: 16px 20px; + margin-bottom: 10px; + box-shadow: 0 3px 14px rgb(251 191 36 / 18%); + gap: 16px; + + .wh-left { + display: flex; + align-items: center; + gap: 16px; + } + + .wh-trophy { + width: 50px; + height: 50px; + background: linear-gradient(135deg, #b45309, #78350f); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgb(120 53 15 / 30%); + flex-shrink: 0; + + mat-icon { + color: #fde68a; + font-size: 26px; + height: 26px; + width: 26px; + } + } + + .wh-label { display: block; - font-size: 11px; - color: #975a16; + font-size: 9px; + font-weight: 800; + color: #92400e; + letter-spacing: 2px; text-transform: uppercase; - letter-spacing: 1px; margin-bottom: 4px; } - h4 { + .wh-name { font-size: 20px; + font-weight: 800; + color: #78350f; + margin: 0 0 7px; + line-height: 1.2; + } + + .wh-stats { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; + } + + .wh-votes { + font-size: 13px; font-weight: 700; - color: #744210; - margin: 0 0 4px; + color: #92400e; + background: rgb(254 243 199 / 80%); + padding: 2px 9px; + border-radius: 20px; + border: 1px solid #fcd34d; } - .winner-votes { + .wh-pct { + font-size: 13px; + font-weight: 700; + color: #b45309; + } + + .wh-rank { + font-size: 12px; + color: #a16207; + font-weight: 500; + } + + .wh-dot { + color: #d97706; font-size: 14px; - color: #975a16; - font-weight: 600; + } + + .wh-donut { + width: 78px; + height: 78px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2px 10px rgb(0 0 0 / 10%); + } + + .wh-donut-hole { + width: 54px; + height: 54px; + background: #fffbeb; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + + .wh-donut-pct { + font-size: 13px; + font-weight: 800; + color: #92400e; + line-height: 1; + } + + .wh-donut-label { + font-size: 8px; + color: #a16207; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } } } -.results-table { - padding: 16px 24px 8px; -} - -.result-row { +/* ── Multiple Winners ── */ +.winners-header { display: flex; align-items: center; - gap: 14px; - padding: 12px 0; - border-bottom: 1px solid #f7fafc; + gap: 8px; + margin-bottom: 10px; + font-size: 14px; + font-weight: 700; + color: #92400e; + + mat-icon { + color: #d97706; + font-size: 18px; + height: 18px; + width: 18px; + } + + .wh-seat-note { + font-size: 12px; + font-weight: 400; + color: #a16207; + margin-left: 2px; + } +} + +.winners-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; +} + +.winner-card { + flex: 1 1 200px; + max-width: 280px; + display: flex; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, #fffbeb, #fef9c3); + border: 1.5px solid #fcd34d; + border-radius: 12px; + padding: 10px 14px; + box-shadow: 0 2px 8px rgb(251 191 36 / 15%); + + .wc-rank { + width: 30px; + height: 30px; + background: linear-gradient(135deg, #fcd34d, #f59e0b); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + color: #78350f; + flex-shrink: 0; + } + + .wc-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .wc-name { + font-size: 13px; + font-weight: 700; + color: #78350f; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .wc-votes { + font-size: 11px; + color: #a16207; + } + + .wc-trophy-icon { + color: #d97706; + font-size: 18px; + height: 18px; + width: 18px; + flex-shrink: 0; + } +} + +/* ── Vote Chart ── */ +.vote-chart { + background: #fff; + border: 1px solid #f1f5f9; + border-radius: 16px; + padding: 20px 24px 6px; + box-shadow: 0 2px 12px rgb(0 0 0 / 4%); +} + +.vc-dist-strip { + display: flex; + height: 10px; + border-radius: 6px; + overflow: hidden; + margin-bottom: 4px; + gap: 2px; + + .vcd-seg { + background: #cbd5e1; + border-radius: 3px; + transition: flex 0.6s ease; + + &.vcd-won { + background: linear-gradient(90deg, #fbbf24, #d97706); + } + } +} + +.vc-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid #f8fafc; &:last-child { border-bottom: none; + padding-bottom: 4px; } +} - &.winner .candidate-name { +.vc-meta { + display: flex; + align-items: center; + gap: 10px; + width: 175px; + flex-shrink: 0; +} + +.vc-rank { + width: 30px; + height: 30px; + border-radius: 50%; + background: #f1f5f9; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #94a3b8; + flex-shrink: 0; + + &.vc-rank-won { + background: linear-gradient(135deg, #fcd34d, #f59e0b); + + mat-icon { + color: #78350f; + font-size: 16px; + height: 16px; + width: 16px; + } + } +} + +.vc-name-wrap { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.vc-name { + font-size: 13px; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .vc-won & { + color: #78350f; font-weight: 700; - color: #744210; + } +} + +.vc-badge { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 7px; + border-radius: 20px; + align-self: flex-start; + + &.vcb-won { + background: #fef9c3; + color: #92400e; } - .rank { - width: 40px; - text-align: center; - font-size: 14px; - font-weight: 600; - color: #a0aec0; + &.vcb-lost { + background: #f1f5f9; + color: #94a3b8; + } +} - .won-icon { - color: #d69e2e; - font-size: 22px; - height: 22px; - width: 22px; - } +.vc-bar-area { + flex: 1; + display: flex; + align-items: center; + gap: 10px; +} + +.vc-bar-track { + flex: 1; + height: 32px; + background: #f8fafc; + border-radius: 8px; + overflow: hidden; + border: 1px solid #f1f5f9; +} + +.vc-bar-fill { + height: 100%; + border-radius: 8px; + background: linear-gradient(90deg, #e2e8f0, #cbd5e1); + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 10px; + animation: bar-expand 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards; + min-width: 40px; + + .vc-bar-label { + font-size: 13px; + font-weight: 800; + color: #475569; + white-space: nowrap; } - .candidate-name { - width: 200px; - font-size: 15px; - color: #2d3748; - } + &.vcbf-won { + background: linear-gradient(90deg, #fde68a 0%, #fbbf24 55%, #d97706 100%); - .vote-bar { - flex: 1; - display: flex; - align-items: center; - gap: 12px; - - .bar-fill { - height: 10px; - border-radius: 5px; - background: #bee3f8; - transition: width 0.5s ease; - - &.bar-won { - background: #f6ad55; - } - } - - .vote-count { - font-size: 13px; - color: #718096; - white-space: nowrap; + .vc-bar-label { + color: #78350f; } } +} - .result-badge { - padding: 3px 12px; - border-radius: 12px; - font-size: 12px; - font-weight: 700; - width: 50px; - text-align: center; - - &.won { - background: #fefcbf; - color: #744210; - } - - &.lost { - background: #edf2f7; - color: #a0aec0; - } +@keyframes bar-expand { + from { + width: 0; } } +.vc-pct-out { + font-size: 13px; + font-weight: 700; + color: #64748b; + min-width: 48px; + text-align: right; + white-space: nowrap; +} + .loading-state, .empty-state { display: flex; @@ -285,3 +601,361 @@ transform: rotate(360deg); } } + +/* ── Manual Vote Entry Panel ── */ +.manual-entry-panel { + background: #fff; + border: 2px solid #e9d8fd; + border-radius: 14px; + padding: 24px; + margin-bottom: 28px; + box-shadow: 0 2px 12px rgb(102 126 234 / 10%); +} + +.mep-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.mep-header-left { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.mep-icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: #f3e8ff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + mat-icon { + color: #7c3aed; + font-size: 22px; + } +} + +.mep-header-left h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; + color: #2d3748; +} + +.mep-header-left p { + margin: 0; + font-size: 13px; + color: #718096; +} + +.saved-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: #f0fff4; + border: 1px solid #9ae6b4; + color: #276749; + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: 20px; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } +} + +.mep-post-block { + margin-bottom: 20px; +} + +.mep-post-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + color: #553c9a; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } +} + +.mep-table { + border: 1px solid #e9d8fd; + border-radius: 10px; + overflow: hidden; +} + +.mep-table-head { + display: grid; + grid-template-columns: 60px 1fr 180px; + background: #faf5ff; + padding: 10px 16px; + font-size: 11px; + font-weight: 700; + color: #805ad5; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.mep-row { + display: grid; + grid-template-columns: 60px 1fr 180px; + align-items: center; + padding: 10px 16px; + border-top: 1px solid #f3e8ff; + + &:hover { + background: #faf5ff; + } +} + +.col-serial { + font-size: 13px; + font-weight: 600; + color: #805ad5; +} + +.col-name { + font-size: 14px; + color: #2d3748; + font-weight: 500; +} + +.col-votes { + display: flex; + align-items: center; +} + +.vote-input { + width: 120px; + padding: 6px 10px; + border: 1px solid #d6bcfa; + border-radius: 6px; + font-size: 15px; + font-weight: 700; + color: #2d3748; + text-align: center; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; + + &:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgb(124 58 237 / 15%); + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + opacity: 1; + } +} + +.mep-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 20px; + flex-wrap: wrap; +} + +.btn-save-counts { + display: inline-flex; + align-items: center; + gap: 6px; + background: #6b46c1; + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + + mat-icon { + font-size: 18px; + } + + &:hover:not(:disabled) { + background: #553c9a; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-declare-inline { + display: inline-flex; + align-items: center; + gap: 6px; + background: #744210; + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + + mat-icon { + font-size: 18px; + } + + &:hover:not(:disabled) { + background: #5a3309; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.declare-hint { + font-size: 12px; + color: #e53e3e; + font-style: italic; +} + +.btn-edit-counts { + display: inline-flex; + align-items: center; + gap: 6px; + background: #fff; + color: #6b46c1; + border: 2px solid #6b46c1; + padding: 9px 18px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: + background 0.2s, + color 0.2s; + + mat-icon { + font-size: 18px; + } + + &:hover { + background: #6b46c1; + color: #fff; + } +} + +.btn-cancel-edit { + display: inline-flex; + align-items: center; + gap: 6px; + background: #fff; + color: #718096; + border: 1px solid #cbd5e0; + padding: 9px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + mat-icon { + font-size: 18px; + } + + &:hover { + background: #f7fafc; + } +} + +.edit-reason-row { + width: 100%; + margin-bottom: 10px; +} + +.reason-input { + width: 100%; + padding: 9px 14px; + border: 1px solid #d6bcfa; + border-radius: 8px; + font-size: 14px; + color: #2d3748; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; + + &:focus { + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgb(124 58 237 / 15%); + } + + &::placeholder { + color: #a0aec0; + } +} + +/* ── Unopposed Election Banner ── */ +.unopposed-banner { + display: flex; + align-items: center; + gap: 14px; + background: linear-gradient(135deg, #e6fffa, #b2f5ea); + border: 1px solid #81e6d9; + border-radius: 10px; + padding: 16px 20px; + + mat-icon { + color: #2c7a7b; + font-size: 28px; + height: 28px; + width: 28px; + flex-shrink: 0; + } + + .unopposed-info { + flex: 1; + + strong { + display: block; + font-size: 15px; + font-weight: 700; + color: #234e52; + } + + span { + font-size: 12px; + color: #2c7a7b; + } + } + + .unopposed-chip { + background: #2c7a7b; + color: #fff; + padding: 4px 12px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + white-space: nowrap; + } +} diff --git a/src/app/election/components/election-results/election-results.component.ts b/src/app/election/components/election-results/election-results.component.ts index e8470504a..83df73eb1 100644 --- a/src/app/election/components/election-results/election-results.component.ts +++ b/src/app/election/components/election-results/election-results.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { RouterModule, ActivatedRoute } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @@ -19,6 +20,7 @@ import { ElectionService } from '../../election.service'; standalone: true, imports: [ CommonModule, + FormsModule, RouterModule, MatIconModule, MatSnackBarModule @@ -34,8 +36,16 @@ export class ElectionResultsComponent implements OnInit { electionId!: number; election: any = null; results: any[] = []; + candidates: any[] = []; loading = true; declaring = false; + savingCounts = false; + countsSaved = false; + editMode = false; + editReason = ''; + + // candidateId → entered vote count + manualCounts: Record = {}; ngOnInit(): void { this.electionId = +this.route.snapshot.params['electionId']; @@ -44,33 +54,122 @@ export class ElectionResultsComponent implements OnInit { } loadElection(): void { - this.service.getElection(this.electionId).subscribe((e) => (this.election = e)); + this.service.getElection(this.electionId).subscribe((e) => { + this.election = e; + if (this.canEnterManualCounts) { + this.loadCandidates(); + } + }); } loadResults(): void { this.loading = true; this.service .getResults(this.electionId) - .pipe( - catchError(() => { - this.loading = false; - return of([]); - }) - ) + .pipe(catchError(() => of([]))) .subscribe((data: any[]) => { this.results = data; this.loading = false; }); } + loadCandidates(): void { + this.service + .getCandidates(this.electionId, undefined, 0, 200) + .pipe(catchError(() => of({ content: [] }))) + .subscribe((data: any) => { + this.candidates = data?.content ?? []; + let anySaved = false; + for (const c of this.candidates) { + this.manualCounts[c.id] = c.manualVoteCount ?? 0; + if (c.manualVoteCount != null) anySaved = true; + } + if (anySaved) this.countsSaved = true; + }); + } + + groupCandidatesByPost(): { postId: number; postName: string; candidates: any[] }[] { + const grouped: Record = {}; + for (const c of this.candidates) { + if (!grouped[c.postId]) grouped[c.postId] = { postId: c.postId, postName: c.postName, candidates: [] }; + grouped[c.postId].candidates.push(c); + } + return Object.values(grouped); + } + + isUnopposed(group: { postId: number; postName: string; candidates: any[] }): boolean { + return group.candidates.length === 1; + } + + get saveButtonLabel(): string { + const hasContested = this.groupCandidatesByPost().some((g) => g.candidates.length > 1); + return hasContested ? 'Save Vote Counts' : 'Confirm Unopposed Election'; + } + + saveManualCounts(): void { + const groups = this.groupCandidatesByPost(); + const contestedGroups = groups.filter((g) => g.candidates.length > 1); + + // All posts are unopposed — no vote counts needed + if (contestedGroups.length === 0) { + this.countsSaved = true; + this.snackBar.open('All candidates elected unopposed — no vote counts required!', 'Close', { duration: 4000 }); + return; + } + + const contestedCandidateIds = new Set(contestedGroups.flatMap((g) => g.candidates.map((c: any) => c.id))); + const counts = this.candidates + .filter((c) => contestedCandidateIds.has(c.id)) + .map((c) => ({ candidateId: c.id, voteCount: this.manualCounts[c.id] ?? 0 })); + + this.savingCounts = true; + const reason = this.editMode ? this.editReason : undefined; + this.service.saveManualVoteCounts(this.electionId, counts, reason).subscribe({ + next: () => { + const wasEditing = this.editMode; + this.savingCounts = false; + this.countsSaved = true; + this.editMode = false; + this.editReason = ''; + this.snackBar.open( + wasEditing ? 'Vote counts updated successfully!' : 'Vote counts saved successfully!', + 'Close', + { duration: 3000 } + ); + }, + error: (err: any) => { + this.savingCounts = false; + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to save vote counts'; + this.snackBar.open(msg, 'Close', { duration: 6000 }); + } + }); + } + + startEdit(): void { + this.editMode = true; + this.editReason = ''; + } + + cancelEdit(): void { + this.editMode = false; + this.editReason = ''; + this.loadCandidates(); + } + declareResults(): void { - if (!confirm('Are you sure you want to declare the final results?')) return; + if (!confirm('Are you sure you want to declare the final results? This cannot be undone.')) return; this.declaring = true; - this.service.declareResults(this.electionId).subscribe((data: any[]) => { - this.results = data; - this.declaring = false; - this.snackBar.open('Results declared successfully!', 'Close', { duration: 4000 }); - this.loadElection(); + this.service.declareResults(this.electionId).subscribe({ + next: (data: any[]) => { + this.results = data; + this.declaring = false; + this.snackBar.open('Results declared successfully!', 'Close', { duration: 4000 }); + this.loadElection(); + }, + error: () => { + this.declaring = false; + this.snackBar.open('Failed to declare results', 'Close', { duration: 4000 }); + } }); } @@ -96,6 +195,29 @@ export class ElectionResultsComponent implements OnInit { return Math.round((voteCount / maxVotes) * 100); } + totalVotes(postResults: any[]): number { + return postResults.reduce((sum, r) => sum + (r.voteCount ?? 0), 0); + } + + votePercent(voteCount: number, postResults: any[]): number { + const total = this.totalVotes(postResults); + if (total === 0) return 0; + return Math.round((voteCount / total) * 1000) / 10; + } + + conicGradient(voteCount: number, postResults: any[]): string { + const pct = this.votePercent(voteCount, postResults); + return `conic-gradient(#f59e0b 0% ${pct}%, #e2e8f0 ${pct}% 100%)`; + } + + winnersOf(postResults: any[]): any[] { + return postResults.filter((r) => r.result === 'WON').sort((a, b) => a.rankPosition - b.rankPosition); + } + + get canEnterManualCounts(): boolean { + return this.election?.status === 'VOTING_OPEN' && this.results.length === 0; + } + get canDeclare(): boolean { return this.election?.status === 'VOTING_CLOSED' && this.results.length === 0; } diff --git a/src/app/election/election-routing.module.ts b/src/app/election/election-routing.module.ts index df877bb9d..e6336e68e 100644 --- a/src/app/election/election-routing.module.ts +++ b/src/app/election/election-routing.module.ts @@ -62,12 +62,6 @@ const routes: Routes = [ import('./components/candidate-board/candidate-board.component').then((m) => m.CandidateBoardComponent), data: { title: 'Candidate Board' } }, - { - path: ':electionId/vote', - loadComponent: () => - import('./components/voting-interface/voting-interface.component').then((m) => m.VotingInterfaceComponent), - data: { title: 'Cast Your Vote' } - }, { path: ':electionId/results', loadComponent: () => diff --git a/src/app/election/election.service.ts b/src/app/election/election.service.ts index ed1ed365d..584f8011c 100644 --- a/src/app/election/election.service.ts +++ b/src/app/election/election.service.ts @@ -140,9 +140,13 @@ export class ElectionService { return this.http.delete(`/election/${electionId}/candidate/${candidateId}`, { params }); } - // ── Voting & Results ─────────────────────────────────────────── - castVote(electionId: number, data: any): Observable { - return this.http.post(`/election/${electionId}/vote`, data); + // ── Manual Voting & Results ──────────────────────────────────── + saveManualVoteCounts( + electionId: number, + counts: { candidateId: number; voteCount: number }[], + reason?: string + ): Observable { + return this.http.post(`/election/${electionId}/manual-vote-counts`, { counts, reason: reason ?? null }); } declareResults(electionId: number): Observable { @@ -153,10 +157,6 @@ export class ElectionService { return this.http.get(`/election/${electionId}/results`); } - getVoteSummary(electionId: number): Observable { - return this.http.get(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null))); - } - getCandidateVoterAnalysis(electionId: number, candidateId: number): Observable { return this.http .get(`/election/${electionId}/candidates/${candidateId}/voter-analysis`)