feat(election): manual vote entry, results declaration, remove electronic voting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-01 15:10:48 +05:30
co-authored by Claude Sonnet 4.6
parent 4666a476fd
commit ed54db2e74
9 changed files with 1242 additions and 237 deletions
@@ -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 {
@@ -81,16 +81,6 @@
@if (election.description) {
<p class="description">{{ election.description }}</p>
}
@if (voteSummaryMap[election.id]) {
<div class="voter-count-row">
<span class="voter-count-chip">
<mat-icon>how_to_vote</mat-icon>
{{ voteSummaryMap[election.id].totalVoters }} members voted
</span>
<span class="vote-total-chip"> {{ voteSummaryMap[election.id].totalVotes }} total votes </span>
</div>
}
</div>
<div class="card-actions">
@@ -122,11 +112,24 @@
<mat-icon>how_to_vote</mat-icon> Open Voting
</button>
}
@if (canVote(election)) {
<a [routerLink]="['/election', election.id, 'vote']" class="btn-vote">
<mat-icon>ballot</mat-icon> Vote Now
@if (election.status === 'VOTING_OPEN') {
<button class="btn-warn" [disabled]="actionLoading[election.id]" (click)="closeVoting(election)">
<mat-icon>lock</mat-icon> Close Voting
</button>
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
<mat-icon>edit_note</mat-icon> Enter Votes
</a>
}
@if (election.status === 'VOTING_CLOSED') {
<button
class="btn-declare-result"
[disabled]="actionLoading[election.id]"
(click)="declareResults(election)"
>
<mat-icon>gavel</mat-icon>
{{ actionLoading[election.id] ? 'Declaring...' : 'Declare Results' }}
</button>
}
@if (canViewResults(election)) {
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
<mat-icon>bar_chart</mat-icon> Results
@@ -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;
@@ -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)[] {
@@ -28,77 +28,247 @@
<div class="spinner"></div>
<p>Loading results...</p>
</div>
} @else if (results.length === 0) {
<div class="empty-state">
<mat-icon>bar_chart</mat-icon>
<h3>Results not yet declared</h3>
@if (canDeclare) {
<p>Voting is closed. Click "Declare Results" to compute and publish the results.</p>
} @else {
<p>Results will appear here once the election is complete and results are declared.</p>
}
</div>
} @else {
<div class="results-meta">
<div class="meta-chip">
<mat-icon>how_to_vote</mat-icon>
Election: {{ election?.electionDate | date: 'dd MMM yyyy' }}
</div>
@if (results[0]?.declaredDate) {
<div class="meta-chip">
<mat-icon>gavel</mat-icon>
Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy HH:mm' }}
</div>
}
</div>
@for (group of groupByPost(); track group.postName) {
<div class="post-result">
<div class="post-result-header">
<h3>{{ group.postName }}</h3>
</div>
<!-- Winner Spotlight -->
@for (result of group.results; track result.id) {
@if (result.rankPosition === 1 && result.result === 'WON') {
<div class="winner-banner">
<div class="trophy-icon"><mat-icon>emoji_events</mat-icon></div>
<div class="winner-info">
<span class="winner-label">Winner</span>
<h4>{{ result.candidateName }}</h4>
<span class="winner-votes">{{ result.voteCount | number }} votes</span>
</div>
<!-- ── Manual Vote Entry (shown when voting is open/closed and results not yet declared) ── -->
@if (canEnterManualCounts && candidates.length > 0) {
<div class="manual-entry-panel">
<div class="mep-header">
<div class="mep-header-left">
<div class="mep-icon"><mat-icon>how_to_vote</mat-icon></div>
<div>
<h3>Enter Manual Vote Counts</h3>
<p>Physical voting has been completed. Enter the vote count received by each candidate below.</p>
</div>
</div>
@if (countsSaved) {
<span class="saved-chip"><mat-icon>check_circle</mat-icon> Counts Saved</span>
}
}
</div>
<!-- Results Table with Bar Chart -->
<div class="results-table">
@for (result of group.results; track result.id) {
<div class="result-row" [class.winner]="result.result === 'WON'">
<div class="rank">
@if (result.result === 'WON') {
<mat-icon class="won-icon">emoji_events</mat-icon>
} @else {
<span># {{ result.rankPosition }}</span>
@for (group of groupCandidatesByPost(); track group.postId) {
<div class="mep-post-block">
<div class="mep-post-name"><mat-icon>badge</mat-icon> {{ group.postName }}</div>
@if (isUnopposed(group)) {
<div class="unopposed-banner">
<mat-icon>how_to_vote</mat-icon>
<div class="unopposed-info">
<strong>{{ group.candidates[0].candidateName }}</strong>
<span>No other candidate filed nomination for this post</span>
</div>
<span class="unopposed-chip">ELECTED UNOPPOSED</span>
</div>
} @else {
<div class="mep-table">
<div class="mep-table-head">
<span class="col-serial">S.No</span>
<span class="col-name">Candidate</span>
<span class="col-votes">Votes Received</span>
</div>
@for (candidate of group.candidates; track candidate.id) {
<div class="mep-row">
<span class="col-serial">#{{ candidate.serialNumber }}</span>
<span class="col-name">{{ candidate.candidateName }}</span>
<span class="col-votes">
<input
type="number"
class="vote-input"
min="0"
[(ngModel)]="manualCounts[candidate.id]"
placeholder="0"
[disabled]="countsSaved && !editMode"
/>
</span>
</div>
}
</div>
<div class="candidate-name">{{ result.candidateName }}</div>
<div class="vote-bar">
<div
class="bar-fill"
[class.bar-won]="result.result === 'WON'"
[style.width.%]="barWidth(result.voteCount, maxVotes(group.results))"
></div>
<span class="vote-count">{{ result.voteCount | number }} votes</span>
</div>
<div class="result-badge" [class.won]="result.result === 'WON'" [class.lost]="result.result === 'LOST'">
{{ result.result }}
</div>
}
</div>
}
<div class="mep-actions">
@if (!countsSaved) {
<button class="btn-save-counts" (click)="saveManualCounts()" [disabled]="savingCounts">
<mat-icon>save</mat-icon>
{{ savingCounts ? 'Saving...' : saveButtonLabel }}
</button>
} @else if (!editMode) {
<button class="btn-edit-counts" (click)="startEdit()"><mat-icon>edit</mat-icon> Edit Vote Counts</button>
} @else {
<div class="edit-reason-row">
<input
type="text"
class="reason-input"
[(ngModel)]="editReason"
placeholder="Reason for updating vote counts (required)"
maxlength="200"
/>
</div>
<button
class="btn-save-counts"
(click)="saveManualCounts()"
[disabled]="savingCounts || !editReason.trim()"
>
<mat-icon>save</mat-icon>
{{ savingCounts ? 'Updating...' : 'Update Vote Counts' }}
</button>
<button class="btn-cancel-edit" (click)="cancelEdit()"><mat-icon>close</mat-icon> Cancel</button>
}
@if (canDeclare) {
<button class="btn-declare-inline" (click)="declareResults()" [disabled]="declaring || !countsSaved">
<mat-icon>gavel</mat-icon>
{{ declaring ? 'Declaring...' : 'Declare Results' }}
</button>
@if (!countsSaved) {
<span class="declare-hint">Pehle counts save karein, phir declare karein</span>
}
}
</div>
</div>
}
@if (results.length === 0 && !canEnterManualCounts) {
<div class="empty-state">
<mat-icon>bar_chart</mat-icon>
<h3>Results not yet declared</h3>
<p>Results will appear here once the election is complete and results are declared.</p>
</div>
}
<!-- ── Declared Results ── -->
@if (results.length > 0) {
<div class="results-header-meta">
<div class="rh-chip">
<mat-icon>calendar_today</mat-icon> Election: {{ election?.electionDate | date: 'dd MMM yyyy' }}
</div>
@if (results[0]?.declaredDate) {
<div class="rh-chip declared">
<mat-icon>gavel</mat-icon> Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy, hh:mm a' }}
</div>
}
</div>
@for (group of groupByPost(); track group.postName) {
<div class="post-results-wrap">
<div class="post-section-header">
<div class="psh-left">
<div class="psh-icon"><mat-icon>badge</mat-icon></div>
<h3>{{ group.postName }}</h3>
</div>
<span class="total-votes-chip">
<mat-icon>how_to_vote</mat-icon>
{{ totalVotes(group.results) | number }} Total Votes
</span>
</div>
@if (winnersOf(group.results).length === 1) {
<div class="winner-hero">
<div class="wh-left">
<div class="wh-trophy"><mat-icon>emoji_events</mat-icon></div>
<div class="wh-info">
<span class="wh-label">{{ group.results.length === 1 ? 'ELECTED UNOPPOSED' : 'WINNER' }}</span>
<h2 class="wh-name">{{ winnersOf(group.results)[0].candidateName }}</h2>
@if (group.results.length > 1) {
<div class="wh-stats">
<span class="wh-votes">{{ winnersOf(group.results)[0].voteCount | number }} votes</span>
<span class="wh-dot">·</span>
<span class="wh-pct"
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}%</span
>
<span class="wh-dot">·</span>
<span class="wh-rank">Rank #1</span>
</div>
} @else {
<div class="wh-stats"><span class="wh-votes">No contest — sole candidate</span></div>
}
</div>
</div>
@if (group.results.length > 1) {
<div
class="wh-donut"
[style.background]="conicGradient(winnersOf(group.results)[0].voteCount, group.results)"
>
<div class="wh-donut-hole">
<span class="wh-donut-pct"
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}%</span
>
<span class="wh-donut-label">share</span>
</div>
</div>
}
</div>
} @else if (winnersOf(group.results).length > 1) {
<div class="winners-header">
<mat-icon>emoji_events</mat-icon>
<span>{{ winnersOf(group.results).length }} Elected</span>
<span class="wh-seat-note">Top {{ winnersOf(group.results).length }} seats filled</span>
</div>
<div class="winners-grid">
@for (winner of winnersOf(group.results); track winner.id) {
<div class="winner-card">
<div class="wc-rank">#{{ winner.rankPosition }}</div>
<div class="wc-info">
<span class="wc-name">{{ winner.candidateName }}</span>
<span class="wc-votes"
>{{ winner.voteCount | number }} votes · {{ votePercent(winner.voteCount, group.results) }}%</span
>
</div>
<mat-icon class="wc-trophy-icon">emoji_events</mat-icon>
</div>
}
</div>
}
<div class="vote-chart">
<div class="vc-dist-strip">
@for (result of group.results; track result.id) {
<div
class="vcd-seg"
[class.vcd-won]="result.result === 'WON'"
[style.flex]="result.voteCount"
[title]="result.candidateName + ': ' + votePercent(result.voteCount, group.results) + '%'"
></div>
}
</div>
@for (result of group.results; track result.id) {
<div class="vc-row" [class.vc-won]="result.result === 'WON'">
<div class="vc-meta">
<div class="vc-rank" [class.vc-rank-won]="result.result === 'WON'">
@if (result.result === 'WON') {
<mat-icon>emoji_events</mat-icon>
} @else {
<span>{{ result.rankPosition }}</span>
}
</div>
<div class="vc-name-wrap">
<span class="vc-name">{{ result.candidateName }}</span>
<span
class="vc-badge"
[class.vcb-won]="result.result === 'WON'"
[class.vcb-lost]="result.result === 'LOST'"
>
{{ result.result }}
</span>
</div>
</div>
<div class="vc-bar-area">
<div class="vc-bar-track">
<div
class="vc-bar-fill"
[class.vcbf-won]="result.result === 'WON'"
[style.width.%]="barWidth(result.voteCount, maxVotes(group.results))"
>
<span class="vc-bar-label">{{ result.voteCount | number }}</span>
</div>
</div>
<span class="vc-pct-out">{{ votePercent(result.voteCount, group.results) }}%</span>
</div>
</div>
}
</div>
</div>
}
}
}
</div>
@@ -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;
}
}
@@ -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<number, number> = {};
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<number, { postId: number; postName: string; candidates: any[] }> = {};
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;
}
@@ -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: () =>
+7 -7
View File
@@ -140,9 +140,13 @@ export class ElectionService {
return this.http.delete(`/election/${electionId}/candidate/${candidateId}`, { params });
}
// ── Voting & Results ───────────────────────────────────────────
castVote(electionId: number, data: any): Observable<any> {
return this.http.post(`/election/${electionId}/vote`, data);
// ── Manual Voting & Results ────────────────────────────────────
saveManualVoteCounts(
electionId: number,
counts: { candidateId: number; voteCount: number }[],
reason?: string
): Observable<any> {
return this.http.post(`/election/${electionId}/manual-vote-counts`, { counts, reason: reason ?? null });
}
declareResults(electionId: number): Observable<any> {
@@ -153,10 +157,6 @@ export class ElectionService {
return this.http.get<any[]>(`/election/${electionId}/results`);
}
getVoteSummary(electionId: number): Observable<any> {
return this.http.get<any>(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
}
getCandidateVoterAnalysis(electionId: number, candidateId: number): Observable<any> {
return this.http
.get<any>(`/election/${electionId}/candidates/${candidateId}/voter-analysis`)