feat(election): modern results dashboard with donut chart, KPI panel, and symbol fix
- Full redesign: backdrop blobs, KPI metrics panel, split-layout card (donut + candidates list) - Donut chart (CSS conic-gradient) per post showing vote share by candidate color - Winner spotlight banner; unopposed candidate celebration card - Fixed loadSymbols() timing bug: results iterated unconditionally, candidates use hasSymbol guard - Added fetchAndCacheSymbol() helper; loadSymbols() called after declareResults() - Fixed saveManualCounts -> saveManualVoteCounts method name mismatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f2f2338c78
commit
bc513e7c89
@@ -6,269 +6,419 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="election-results">
|
||||
<div class="page-header">
|
||||
<a routerLink="/election" class="back-link"><mat-icon>arrow_back</mat-icon></a>
|
||||
<div>
|
||||
<h2>Election Results</h2>
|
||||
@if (election) {
|
||||
<p class="sub-title">{{ election.title }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (canDeclare) {
|
||||
<button class="btn-declare" (click)="declareResults()" [disabled]="declaring">
|
||||
<mat-icon>gavel</mat-icon>
|
||||
{{ declaring ? 'Declaring...' : 'Declare Results' }}
|
||||
</button>
|
||||
}
|
||||
<div class="results-dashboard">
|
||||
<!-- Backdrop Decorative Blobs -->
|
||||
<div class="backdrop-blobs">
|
||||
<div class="blob blob-indigo"></div>
|
||||
<div class="blob blob-cyan"></div>
|
||||
<div class="blob blob-amber"></div>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a routerLink="/election" class="back-link">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</a>
|
||||
<div class="title-section">
|
||||
<h2>Election Results Portal</h2>
|
||||
@if (election) {
|
||||
<p class="election-title">{{ election.title }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
@if (canDeclare) {
|
||||
<button class="btn-declare" (click)="declareResults()" [disabled]="declaring">
|
||||
<mat-icon>gavel</mat-icon>
|
||||
<span>{{ declaring ? 'Declaring...' : 'Declare Results' }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Metrics Panel -->
|
||||
@if (!loading && (results.length > 0 || candidates.length > 0)) {
|
||||
<div class="kpi-panel">
|
||||
<!-- Status KPI -->
|
||||
<div class="kpi-card kpi-status">
|
||||
<div class="kpi-icon-wrapper">
|
||||
<mat-icon>info</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Election Status</span>
|
||||
<span class="status-badge" [class]="election?.status">
|
||||
{{ election?.status?.replace('_', ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Votes KPI -->
|
||||
@if (results.length > 0) {
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon-wrapper" style="background: rgba(99, 102, 241, 0.1); color: #6366f1">
|
||||
<mat-icon>how_to_vote</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Total Votes Cast</span>
|
||||
<span class="kpi-value">{{ kpis.totalVotes | number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Contested Posts KPI -->
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon-wrapper" style="background: rgba(16, 185, 129, 0.1); color: #10b981">
|
||||
<mat-icon>people</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Contested Posts</span>
|
||||
<span class="kpi-value">{{ kpis.contestedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unopposed Posts KPI -->
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-icon-wrapper" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b">
|
||||
<mat-icon>workspace_premium</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">Unopposed Posts</span>
|
||||
<span class="kpi-value">{{ kpis.unopposedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading results...</p>
|
||||
<p>Fetching election results and metadata...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── Manual Vote Entry (shown when voting is open/closed and results not yet declared) ── -->
|
||||
<!-- ── Manual Vote Entry Panel ── -->
|
||||
@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>
|
||||
<div class="panel-header">
|
||||
<div class="panel-header-left">
|
||||
<div class="panel-icon"><mat-icon>how_to_vote</mat-icon></div>
|
||||
<div class="panel-text">
|
||||
<h3>Enter Manual Vote Counts</h3>
|
||||
<p>Physical voting has been completed. Enter the vote count received by each candidate below.</p>
|
||||
<p>Physical voting is complete. Please input the vote counts received by each candidate below.</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (countsSaved) {
|
||||
<span class="saved-chip"><mat-icon>check_circle</mat-icon> Counts Saved</span>
|
||||
<span class="saved-badge"> <mat-icon>check_circle</mat-icon> Counts Saved </span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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 class="entry-grid">
|
||||
@for (group of groupCandidatesByPost(); track group.postId) {
|
||||
<div class="entry-post-card">
|
||||
<div class="entry-post-header">
|
||||
<mat-icon>badge</mat-icon>
|
||||
<h4>{{ group.postName }}</h4>
|
||||
</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>
|
||||
|
||||
@if (isUnopposed(group)) {
|
||||
<div class="unopposed-entry-banner">
|
||||
<mat-icon>workspace_premium</mat-icon>
|
||||
<div class="banner-body">
|
||||
<strong>{{ group.candidates[0].candidateName }}</strong>
|
||||
<span>Elected unopposed (no other nominees)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="badge-elected">ELECTED</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="entry-table">
|
||||
<div class="table-header">
|
||||
<span class="col-sn">S.No</span>
|
||||
<span class="col-candidate">Candidate Name</span>
|
||||
<span class="col-input">Votes Received</span>
|
||||
</div>
|
||||
@for (candidate of group.candidates; track candidate.id; let idx = $index) {
|
||||
<div class="table-row">
|
||||
<span class="col-sn">#{{ candidate.serialNumber }}</span>
|
||||
<span class="col-candidate-name">
|
||||
<div
|
||||
class="avatar-sm"
|
||||
[style.background-color]="candidateColor(idx) + '15'"
|
||||
[style.color]="candidateColor(idx)"
|
||||
>
|
||||
@if (symbolCache[candidate.id]) {
|
||||
<img [src]="symbolCache[candidate.id]" class="avatar-sm-img" alt="" />
|
||||
} @else {
|
||||
{{ candidate.candidateName?.charAt(0) }}
|
||||
}
|
||||
</div>
|
||||
<span>{{ candidate.candidateName }}</span>
|
||||
</span>
|
||||
<span class="col-input-field">
|
||||
<input
|
||||
type="number"
|
||||
class="vote-count-input"
|
||||
min="0"
|
||||
[(ngModel)]="manualCounts[candidate.id]"
|
||||
placeholder="0"
|
||||
[disabled]="countsSaved && !editMode"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mep-actions">
|
||||
<div class="panel-actions">
|
||||
@if (!countsSaved) {
|
||||
<button class="btn-save-counts" (click)="saveManualCounts()" [disabled]="savingCounts">
|
||||
<button class="btn-primary" (click)="saveManualVoteCounts()" [disabled]="savingCounts">
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ savingCounts ? 'Saving...' : saveButtonLabel }}
|
||||
<span>{{ savingCounts ? 'Saving...' : saveButtonLabel }}</span>
|
||||
</button>
|
||||
} @else if (!editMode) {
|
||||
<button class="btn-edit-counts" (click)="startEdit()"><mat-icon>edit</mat-icon> Edit Vote Counts</button>
|
||||
<button class="btn-secondary" (click)="startEdit()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>Edit Vote Counts</span>
|
||||
</button>
|
||||
} @else {
|
||||
<div class="edit-reason-row">
|
||||
<div class="edit-reason-container">
|
||||
<input
|
||||
type="text"
|
||||
class="reason-input"
|
||||
class="reason-text-input"
|
||||
[(ngModel)]="editReason"
|
||||
placeholder="Reason for updating vote counts (required)"
|
||||
placeholder="Provide a reason for updating vote counts (required)"
|
||||
maxlength="200"
|
||||
/>
|
||||
<div class="edit-action-buttons">
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="saveManualVoteCounts()"
|
||||
[disabled]="savingCounts || !editReason.trim()"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
<span>{{ savingCounts ? 'Updating...' : 'Update' }}</span>
|
||||
</button>
|
||||
<button class="btn-cancel" (click)="cancelEdit()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<button class="btn-declare-accent" (click)="declareResults()" [disabled]="declaring || !countsSaved">
|
||||
<mat-icon>gavel</mat-icon>
|
||||
{{ declaring ? 'Declaring...' : 'Declare Results' }}
|
||||
<span>Declare Final Results</span>
|
||||
</button>
|
||||
@if (!countsSaved) {
|
||||
<span class="declare-hint">Pehle counts save karein, phir declare karein</span>
|
||||
<span class="hint-text">Please save vote counts first before declaring results.</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@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 class="empty-results-card">
|
||||
<mat-icon class="empty-icon">bar_chart</mat-icon>
|
||||
<h3>Results are not yet declared</h3>
|
||||
<p>
|
||||
Once the voting period closes and the results are declared by the CEO/Election Officer, the interactive charts
|
||||
and winners list will display here.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Declared Results ── -->
|
||||
<!-- Declared Results Dashboard -->
|
||||
@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 class="results-metadata">
|
||||
<div class="meta-item">
|
||||
<mat-icon>event</mat-icon>
|
||||
<span>Election Date: {{ election?.electionDate | date: 'dd MMM yyyy' }}</span>
|
||||
</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 class="meta-item meta-declared">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>Declared on: {{ results[0].declaredDate | date: 'dd MMM yyyy, hh:mm a' }}</span>
|
||||
</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 class="results-grid">
|
||||
@for (group of groupByPost(); track group.postName) {
|
||||
@if (group.results.length > 1) {
|
||||
<!-- Contested Post Results Card -->
|
||||
<div class="result-post-card">
|
||||
<div class="card-header">
|
||||
<div class="post-header-left">
|
||||
<mat-icon>badge</mat-icon>
|
||||
<h3>{{ group.postName }}</h3>
|
||||
</div>
|
||||
<div class="votes-badge">
|
||||
<mat-icon>how_to_vote</mat-icon>
|
||||
<span>{{ totalVotes(group.results) | number }} votes cast</span>
|
||||
</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 class="card-body-split">
|
||||
<!-- Donut Chart Side -->
|
||||
<div class="chart-column">
|
||||
<div class="donut-chart-container">
|
||||
<div class="donut-chart-svg" [style.background]="getPostConicGradient(group.results)">
|
||||
<div class="donut-center-hole">
|
||||
<span class="total-count">{{ totalVotes(group.results) | number }}</span>
|
||||
<span class="total-label">Total Votes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidates List Side -->
|
||||
<div class="list-column">
|
||||
<!-- Single Winner Spotlight -->
|
||||
@if (winnersOf(group.results).length === 1) {
|
||||
<div class="winner-spotlight">
|
||||
<div class="award-icon"><mat-icon>emoji_events</mat-icon></div>
|
||||
<div class="winner-details">
|
||||
<span class="spotlight-badge">WINNER ELECT</span>
|
||||
<h4 class="spotlight-name">{{ winnersOf(group.results)[0].candidateName }}</h4>
|
||||
</div>
|
||||
<div class="winner-stats">
|
||||
<span class="stat-votes">{{ winnersOf(group.results)[0].voteCount | number }} votes</span>
|
||||
<span class="stat-pct"
|
||||
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}% share</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Joint Winners Spotlight -->
|
||||
@if (winnersOf(group.results).length > 1) {
|
||||
<div class="winner-spotlight multi-winner">
|
||||
<div class="award-icon"><mat-icon>diversity_3</mat-icon></div>
|
||||
<div class="winner-details">
|
||||
<span class="spotlight-badge"
|
||||
>{{ winnersOf(group.results).length }} Candidates Elected (Tie/Multiple)</span
|
||||
>
|
||||
<div class="multi-winner-pills">
|
||||
@for (w of winnersOf(group.results); track w.id) {
|
||||
<span class="winner-pill">{{ w.candidateName }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- List of Candidates -->
|
||||
<div class="candidates-results-list">
|
||||
@for (r of group.results; track r.id; let idx = $index) {
|
||||
<div class="candidate-result-row" [class.winner-row]="r.result === 'WON'">
|
||||
<div class="rank-avatar-col">
|
||||
@if (r.result === 'WON') {
|
||||
<div class="rank-badge rank-winner">
|
||||
<mat-icon>emoji_events</mat-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rank-badge">
|
||||
{{ r.rankPosition }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="candidate-avatar">
|
||||
@if (symbolCache[r.candidateId]) {
|
||||
<img [src]="symbolCache[r.candidateId]" class="avatar-image" alt="Symbol" />
|
||||
} @else {
|
||||
<div
|
||||
class="avatar-placeholder"
|
||||
[style.background-color]="candidateColor(idx) + '20'"
|
||||
[style.color]="candidateColor(idx)"
|
||||
>
|
||||
{{ r.candidateName?.charAt(0) }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-details">
|
||||
<div class="row-meta">
|
||||
<span class="candidate-name">
|
||||
{{ r.candidateName }}
|
||||
@if (r.result === 'WON') {
|
||||
<span class="won-chip">Elected</span>
|
||||
}
|
||||
</span>
|
||||
<div class="votes-share">
|
||||
<span class="vote-num">{{ r.voteCount | number }}</span>
|
||||
<span
|
||||
class="vote-pct-badge"
|
||||
[style.background-color]="candidateColor(idx) + '15'"
|
||||
[style.color]="candidateColor(idx)"
|
||||
>
|
||||
{{ votePercent(r.voteCount, group.results) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-progress-container">
|
||||
<div
|
||||
class="bar-progress-fill"
|
||||
[style.background-color]="candidateColor(idx)"
|
||||
[style.width.%]="barWidth(r.voteCount, maxVotes(group.results))"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
} @else {
|
||||
<!-- Unopposed Post Card Celebration Layout -->
|
||||
<div class="result-post-card unopposed-post-card">
|
||||
<div class="card-header">
|
||||
<div class="post-header-left">
|
||||
<mat-icon>badge</mat-icon>
|
||||
<h3>{{ group.postName }}</h3>
|
||||
</div>
|
||||
<span class="unopposed-status-badge">ELECTED UNOPPOSED</span>
|
||||
</div>
|
||||
|
||||
<div class="celebration-body">
|
||||
<div class="confetti-decor"></div>
|
||||
<div class="celebration-icon">
|
||||
<mat-icon>workspace_premium</mat-icon>
|
||||
</div>
|
||||
|
||||
<div class="celebration-avatar-wrapper">
|
||||
@if (symbolCache[group.results[0].candidateId]) {
|
||||
<img [src]="symbolCache[group.results[0].candidateId]" class="celebration-avatar-img" alt="" />
|
||||
} @else {
|
||||
<div class="celebration-avatar-placeholder">
|
||||
{{ group.results[0].candidateName?.charAt(0) }}
|
||||
</div>
|
||||
}
|
||||
<div class="celebration-avatar-glow"></div>
|
||||
</div>
|
||||
|
||||
<div class="celebration-text">
|
||||
<span class="celebration-sub-title">Winner Elect</span>
|
||||
<h4 class="celebration-name">{{ group.results[0].candidateName }}</h4>
|
||||
<p class="celebration-desc">
|
||||
Elected unopposed as the sole candidate for the position of <strong>{{ group.postName }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ 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';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { ElectionService } from '../../election.service';
|
||||
|
||||
@@ -32,6 +33,7 @@ export class ElectionResultsComponent implements OnInit {
|
||||
private service = inject(ElectionService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
electionId!: number;
|
||||
election: any = null;
|
||||
@@ -46,6 +48,20 @@ export class ElectionResultsComponent implements OnInit {
|
||||
|
||||
// candidateId → entered vote count
|
||||
manualCounts: Record<number, number> = {};
|
||||
symbolCache: Record<number, SafeResourceUrl | null> = {};
|
||||
|
||||
readonly candidateColors = [
|
||||
'#6366f1', // Indigo
|
||||
'#10b981', // Emerald
|
||||
'#f59e0b', // Amber
|
||||
'#3b82f6', // Blue
|
||||
'#ec4899', // Pink
|
||||
'#8b5cf6', // Violet
|
||||
'#ef4444', // Red
|
||||
'#06b6d4', // Cyan
|
||||
'#f97316', // Orange
|
||||
'#14b8a6' // Teal
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.electionId = +this.route.snapshot.params['electionId'];
|
||||
@@ -56,9 +72,7 @@ export class ElectionResultsComponent implements OnInit {
|
||||
loadElection(): void {
|
||||
this.service.getElection(this.electionId).subscribe((e) => {
|
||||
this.election = e;
|
||||
if (this.canEnterManualCounts) {
|
||||
this.loadCandidates();
|
||||
}
|
||||
this.loadCandidates();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +84,7 @@ export class ElectionResultsComponent implements OnInit {
|
||||
.subscribe((data: any[]) => {
|
||||
this.results = data;
|
||||
this.loading = false;
|
||||
this.loadSymbols();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,9 +100,32 @@ export class ElectionResultsComponent implements OnInit {
|
||||
if (c.manualVoteCount != null) anySaved = true;
|
||||
}
|
||||
if (anySaved) this.countsSaved = true;
|
||||
this.loadSymbols();
|
||||
});
|
||||
}
|
||||
|
||||
loadSymbols(): void {
|
||||
// Results: always try — no hasSymbol guard (result objects don't carry it)
|
||||
for (const r of this.results) {
|
||||
const cid = r.candidateId;
|
||||
if (!cid || this.symbolCache[cid] !== undefined) continue;
|
||||
this.fetchAndCacheSymbol(cid);
|
||||
}
|
||||
// Candidates: respect hasSymbol to avoid redundant 404s in manual-entry mode
|
||||
for (const c of this.candidates) {
|
||||
const cid = c.id;
|
||||
if (!cid || this.symbolCache[cid] !== undefined) continue;
|
||||
if (!c.hasSymbol) continue;
|
||||
this.fetchAndCacheSymbol(cid);
|
||||
}
|
||||
}
|
||||
|
||||
private fetchAndCacheSymbol(cid: number): void {
|
||||
this.service.getCandidateSymbol(this.electionId, cid).subscribe((base64) => {
|
||||
this.symbolCache[cid] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
|
||||
});
|
||||
}
|
||||
|
||||
groupCandidatesByPost(): { postId: number; postName: string; candidates: any[] }[] {
|
||||
const grouped: Record<number, { postId: number; postName: string; candidates: any[] }> = {};
|
||||
for (const c of this.candidates) {
|
||||
@@ -106,7 +144,7 @@ export class ElectionResultsComponent implements OnInit {
|
||||
return hasContested ? 'Save Vote Counts' : 'Confirm Unopposed Election';
|
||||
}
|
||||
|
||||
saveManualCounts(): void {
|
||||
saveManualVoteCounts(): void {
|
||||
const groups = this.groupCandidatesByPost();
|
||||
const contestedGroups = groups.filter((g) => g.candidates.length > 1);
|
||||
|
||||
@@ -163,6 +201,7 @@ export class ElectionResultsComponent implements OnInit {
|
||||
next: (data: any[]) => {
|
||||
this.results = data;
|
||||
this.declaring = false;
|
||||
this.loadSymbols();
|
||||
this.snackBar.open('Results declared successfully!', 'Close', { duration: 4000 });
|
||||
this.loadElection();
|
||||
},
|
||||
@@ -205,9 +244,55 @@ export class ElectionResultsComponent implements OnInit {
|
||||
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%)`;
|
||||
candidateColor(index: number): string {
|
||||
return this.candidateColors[index % this.candidateColors.length];
|
||||
}
|
||||
|
||||
getPostConicGradient(postResults: any[]): string {
|
||||
const total = this.totalVotes(postResults);
|
||||
if (total === 0) return 'conic-gradient(#e2e8f0 0% 100%)';
|
||||
|
||||
const parts: string[] = [];
|
||||
let accumulated = 0;
|
||||
postResults.forEach((r, idx) => {
|
||||
const pct = ((r.voteCount ?? 0) / total) * 100;
|
||||
if (pct > 0) {
|
||||
const color = this.candidateColor(idx);
|
||||
parts.push(`${color} ${accumulated}% ${accumulated + pct}%`);
|
||||
accumulated += pct;
|
||||
}
|
||||
});
|
||||
|
||||
if (parts.length === 0) return 'conic-gradient(#e2e8f0 0% 100%)';
|
||||
return `conic-gradient(${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
get kpis(): { totalVotes: number; contestedCount: number; unopposedCount: number } {
|
||||
let totalVotes = 0;
|
||||
let contestedCount = 0;
|
||||
let unopposedCount = 0;
|
||||
|
||||
if (this.results.length > 0) {
|
||||
const grouped = this.groupByPost();
|
||||
totalVotes = this.results.reduce((sum, r) => sum + (r.voteCount ?? 0), 0);
|
||||
for (const g of grouped) {
|
||||
if (g.results.length > 1) {
|
||||
contestedCount++;
|
||||
} else {
|
||||
unopposedCount++;
|
||||
}
|
||||
}
|
||||
} else if (this.candidates.length > 0) {
|
||||
const grouped = this.groupCandidatesByPost();
|
||||
for (const g of grouped) {
|
||||
if (g.candidates.length > 1) {
|
||||
contestedCount++;
|
||||
} else {
|
||||
unopposedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { totalVotes, contestedCount, unopposedCount };
|
||||
}
|
||||
|
||||
winnersOf(postResults: any[]): any[] {
|
||||
|
||||
Reference in New Issue
Block a user