- @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) {
+
+
+ @for (group of groupByPost(); track group.postName) {
+
+
+
+ @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) {
+
+
+ @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) {
+
+
+
+
+
+ {{ 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`)