feat(election): candidate photos/symbols, voting interface, withdrawal, live vote counts

- Candidate board: show client profile photos and election symbols via CBS ContentStore
  (lazy-fetch base64 via GET /election/{id}/candidate/{id}/symbol), zoom/drag viewer
- Candidate board: live vote count badge per candidate (VOTING_OPEN/CLOSED only)
- Election dashboard: total voters/votes chips on VOTING_OPEN election cards
- Voting interface: fix candidates not loading (paged response .content extraction),
  lazy-load candidate photos and symbols, gold-bordered symbol on ballot card
- Nomination withdrawal: implement withdraw button in nomination-detail and nomination-list
- ImagePreviewDialogComponent: DomSanitizer support for data: URLs
- election.service: uploadCandidateSymbol (FormData), getCandidateSymbol, getClientImage,
  getVoteSummary methods
- main.scss: ipd-dialog / ipd-backdrop styles for image preview dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-26 18:38:11 +05:30
co-authored by Claude Sonnet 4.6
parent 9e33392b01
commit f1b829a100
19 changed files with 1217 additions and 182 deletions
@@ -8,6 +8,7 @@
import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@@ -439,7 +440,7 @@ export class JointHolderDialogComponent implements OnInit {
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'zoom-in'"
>
<img
[src]="data.url"
[src]="safeUrl"
[alt]="data.title"
class="ipd-img"
[style.transform]="imgTransform"
@@ -548,6 +549,11 @@ export class JointHolderDialogComponent implements OnInit {
export class ImagePreviewDialogComponent {
dialogRef = inject<MatDialogRef<ImagePreviewDialogComponent>>(MatDialogRef);
data = inject<{ url: string; title: string }>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeUrl(): SafeResourceUrl {
return this.sanitizer.bypassSecurityTrustResourceUrl(this.data.url);
}
zoom = 1;
tx = 0;
+8 -2
View File
@@ -57,10 +57,16 @@
<!-- Toolbar always visible (shows CBSNexora, Branch, Session info) -->
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
<!-- Simplified menu bar — cashier OR clerk fullscreen mode. The review screen renders its own nav. -->
<!-- Simplified menu bar — cashier OR clerk OR election fullscreen mode. The review screen renders its own nav. -->
@if (hideShellLayout && !isWithdrawalReviewUrl) {
<!-- Election fullscreen — just an ESC hint bar -->
@if (currentUrl.includes('/election')) {
<nav class="cashier-nav cashier-nav--election">
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@if (currentUrl.includes('/clerk/')) {
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
<a
class="cashier-nav__item cashier-nav__item--primary"
+7 -3
View File
@@ -137,7 +137,8 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/today-activity') ||
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
this.hideShellLayout = isCashierOnly || isClerkRoute || this.isWithdrawalReviewUrl;
const isElectionRoute = url.includes('/election');
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
@@ -174,10 +175,13 @@ export class ShellComponent implements OnInit, OnDestroy {
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / review screen, restoring full shell header. */
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.hideShellLayout && (this.router.url.includes('/clerk/') || this.isWithdrawalReviewUrl)) {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
}
@@ -58,12 +58,18 @@
</div>
<div class="candidates-grid">
@for (candidate of group.candidates; track candidate.id) {
<div class="candidate-card" [class.inactive]="!candidate.isActive">
<div class="serial-badge"># {{ candidate.serialNumber }}</div>
<div class="candidate-card" [class.inactive]="!candidate.isActive" (click)="openDetail(candidate)">
<div class="serial-badge">#{{ candidate.serialNumber }}</div>
<div class="candidate-avatar">
@if (candidate.photoUrl) {
<img [src]="candidate.photoUrl" [alt]="candidate.candidateName" />
@if (getCandidatePhoto(candidate)) {
<img
[src]="getCandidatePhoto(candidate)"
[alt]="candidate.candidateName"
class="clickable-photo"
(click)="openPhotoViewer(candidate, $event)"
title="Click to view"
/>
} @else {
<div class="avatar-placeholder">
{{ candidate.candidateName.charAt(0).toUpperCase() }}
@@ -71,28 +77,40 @@
}
</div>
<div class="candidate-info">
<h4>{{ candidate.candidateName }}</h4>
@if (candidate.symbol) {
<div class="symbol">Symbol: {{ candidate.symbol }}</div>
}
<div class="meta-row">
@if (candidate.gender) {
<span>{{ candidate.gender }}</span>
}
@if (candidate.caste) {
<span>{{ candidate.caste }}</span>
}
@if (candidate.location) {
<span class="location"><mat-icon>location_on</mat-icon>{{ candidate.location }}</span>
}
<div class="candidate-name">{{ candidate.candidateName }}</div>
@if (showVoteCounts) {
<div class="vote-count-badge">
<mat-icon>how_to_vote</mat-icon>
{{ getVoteCount(candidate) }} votes
</div>
@if (candidate.briefProfile) {
<p class="brief-profile">{{ candidate.briefProfile }}</p>
}
<div class="symbol-area" (click)="$event.stopPropagation()">
@if (getSymbol(candidate)) {
<img
[src]="getSymbol(candidate)"
class="symbol-thumb"
title="Click to view symbol"
(click)="openSymbolViewer(candidate, $event)"
/>
<span class="symbol-label">Election Symbol</span>
} @else {
<span class="no-symbol-text">No symbol</span>
}
<label class="btn-symbol-upload" [class.uploading]="symbolUploading[candidate.id]">
<mat-icon>{{ symbolUploading[candidate.id] ? 'hourglass_top' : 'upload' }}</mat-icon>
{{ candidate.hasSymbol ? 'Change' : 'Upload' }}
<input
type="file"
accept="image/*"
style="display: none"
(change)="onSymbolFileChange(candidate, $event)"
/>
</label>
</div>
<div class="candidate-actions">
<div class="candidate-actions" (click)="$event.stopPropagation()">
@if (!candidate.isActive) {
<span class="removed-badge">Removed</span>
} @else {
@@ -149,3 +167,135 @@
}
}
</div>
@if (selectedCandidate) {
<div class="drawer-overlay" (click)="closeDetail()"></div>
<div class="candidate-drawer">
<div class="drawer-header">
<h3>Candidate Details</h3>
<button class="drawer-close" (click)="closeDetail()"><mat-icon>close</mat-icon></button>
</div>
<div class="drawer-body">
<div class="drawer-avatar-wrap">
@if (getCandidatePhoto(selectedCandidate)) {
<img
[src]="getCandidatePhoto(selectedCandidate)"
class="drawer-avatar-img clickable-photo"
[alt]="selectedCandidate.candidateName"
(click)="openPhotoViewer(selectedCandidate, $event)"
title="Click to view"
/>
} @else {
<div class="drawer-avatar-placeholder">
{{ selectedCandidate.candidateName.charAt(0).toUpperCase() }}
</div>
}
<div class="drawer-serial">#{{ selectedCandidate.serialNumber }}</div>
</div>
<div class="drawer-name">{{ selectedCandidate.candidateName }}</div>
<div class="drawer-post">{{ selectedCandidate.postName }}</div>
@if (showVoteCounts) {
<div class="drawer-vote-count">
<mat-icon>how_to_vote</mat-icon>
{{ getVoteCount(selectedCandidate) }} votes received
</div>
}
@if (selectedCandidate.approvedDate) {
<div class="drawer-meta-row">
<mat-icon>event_available</mat-icon>
<span>Approved on {{ selectedCandidate.approvedDate | date: 'dd MMM yyyy, hh:mm a' }}</span>
</div>
}
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>how_to_vote</mat-icon> Election Symbol</div>
<div class="symbol-preview-area">
@if (getSymbol(selectedCandidate)) {
<div
class="symbol-seal-wrap clickable-photo"
(click)="openSymbolViewer(selectedCandidate, $event)"
title="Click to view"
>
<img [src]="getSymbol(selectedCandidate)" class="symbol-preview-img" alt="Election Symbol" />
<span class="symbol-seal-label">Election Symbol</span>
</div>
} @else {
<div class="symbol-empty">
<mat-icon>how_to_vote</mat-icon>
<span>No symbol yet</span>
</div>
}
<label class="btn-upload-symbol" [class.uploading]="symbolUploading[selectedCandidate.id]">
<mat-icon>{{ symbolUploading[selectedCandidate.id] ? 'hourglass_top' : 'upload_file' }}</mat-icon>
{{
symbolUploading[selectedCandidate.id]
? 'Uploading...'
: selectedCandidate.hasSymbol
? 'Change Symbol'
: 'Upload Symbol'
}}
<input
type="file"
accept="image/*"
style="display: none"
(change)="onSymbolFileChange(selectedCandidate, $event)"
/>
</label>
</div>
</div>
@if (selectedCandidate.briefProfile) {
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>person</mat-icon> Brief Profile</div>
<p class="drawer-profile-text">{{ selectedCandidate.briefProfile }}</p>
</div>
}
<div class="drawer-section">
<div class="drawer-section-title"><mat-icon>info</mat-icon> Details</div>
<div class="drawer-info-grid">
<div class="info-item">
<span class="info-label">Serial No.</span>
<span class="info-value">#{{ selectedCandidate.serialNumber }}</span>
</div>
<div class="info-item">
<span class="info-label">Post</span>
<span class="info-value">{{ selectedCandidate.postName }}</span>
</div>
<div class="info-item">
<span class="info-label">Status</span>
<span
class="info-value"
[class.text-green]="selectedCandidate.isActive"
[class.text-red]="!selectedCandidate.isActive"
>
{{ selectedCandidate.isActive ? 'Active' : 'Removed' }}
</span>
</div>
@if (selectedCandidate.nominationId) {
<div class="info-item">
<span class="info-label">Nomination</span>
<a
[routerLink]="['/election', electionId, 'nomination', selectedCandidate.nominationId]"
class="info-link"
(click)="closeDetail()"
>
View Nomination <mat-icon>open_in_new</mat-icon>
</a>
</div>
}
</div>
</div>
@if (selectedCandidate.isActive) {
<button class="btn-remove-full" (click)="removeCandidate(selectedCandidate)">
<mat-icon>person_remove</mat-icon> Remove Candidate
</button>
}
</div>
</div>
}
@@ -85,6 +85,7 @@
color: #718096;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
&.active {
background: #2563eb;
@@ -122,7 +123,7 @@
.candidates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
@@ -130,12 +131,17 @@
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px;
padding: 18px 16px 14px;
position: relative;
transition: box-shadow 0.2s;
transition:
box-shadow 0.2s,
transform 0.15s;
cursor: pointer;
text-align: center;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
box-shadow: 0 6px 20px rgb(0 0 0 / 14%);
transform: translateY(-2px);
}
&.inactive {
@@ -144,114 +150,177 @@
.serial-badge {
position: absolute;
top: 12px;
right: 12px;
top: 10px;
right: 10px;
background: #eff6ff;
color: #2563eb;
padding: 2px 10px;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-size: 11px;
font-weight: 700;
}
}
.clickable-photo {
cursor: zoom-in;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
}
.candidate-avatar {
display: flex;
justify-content: center;
margin-bottom: 16px;
margin-bottom: 12px;
img {
width: 80px;
height: 80px;
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e2e8f0;
}
.avatar-placeholder {
width: 80px;
height: 80px;
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1e40af);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-size: 28px;
font-weight: 700;
}
}
.candidate-info {
text-align: center;
.candidate-name {
font-size: 14px;
font-weight: 600;
color: #2d3748;
margin-bottom: 4px;
line-height: 1.3;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #2d3748;
margin: 0 0 6px;
}
.vote-count-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
font-weight: 700;
margin: 0 auto 8px;
.symbol {
mat-icon {
font-size: 12px;
color: #718096;
margin-bottom: 8px;
height: 12px;
width: 12px;
}
}
.meta-row {
display: flex;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
.symbol-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 10px;
span {
font-size: 12px;
background: #f7fafc;
padding: 2px 8px;
border-radius: 10px;
color: #718096;
display: inline-flex;
align-items: center;
gap: 2px;
.symbol-thumb {
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 50%;
background: #fff;
padding: 6px;
border: 3px solid #f59e0b;
box-shadow:
0 0 0 3px #fef3c7,
0 4px 12px rgb(245 158 11 / 25%);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
&:hover {
transform: scale(1.12);
box-shadow:
0 0 0 3px #fde68a,
0 6px 18px rgb(245 158 11 / 40%);
}
}
.brief-profile {
font-size: 12px;
.symbol-label {
font-size: 10px;
font-weight: 600;
color: #b45309;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.no-symbol-text {
font-size: 11px;
color: #a0aec0;
line-height: 1.5;
text-align: center;
}
}
.btn-symbol-upload {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 10px;
border: 1px dashed #a0aec0;
border-radius: 6px;
background: #f8fafc;
color: #4a5568;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
&.uploading {
opacity: 0.6;
pointer-events: none;
}
}
.candidate-actions {
display: flex;
justify-content: center;
margin-top: 14px;
margin-top: 6px;
.btn-remove {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 3px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
padding: 5px 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-size: 11px;
cursor: pointer;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
@@ -262,12 +331,399 @@
.removed-badge {
background: #edf2f7;
color: #a0aec0;
padding: 5px 12px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-size: 11px;
}
}
// ── Candidate Detail Drawer ────────────────────────────────────
.drawer-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 30%);
z-index: 999;
animation: fade-in 0.2s ease;
}
.candidate-drawer {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
background: #fff;
z-index: 1000;
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgb(0 0 0 / 15%);
animation: slide-in 0.25s ease;
overflow: hidden;
@media (width <= 480px) {
width: 100vw;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fafbfc;
flex-shrink: 0;
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #2d3748;
}
.drawer-close {
background: none;
border: none;
cursor: pointer;
color: #718096;
display: flex;
align-items: center;
padding: 4px;
border-radius: 6px;
&:hover {
background: #f0f0f0;
color: #2d3748;
}
}
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.drawer-avatar-wrap {
display: flex;
justify-content: center;
position: relative;
margin-bottom: 14px;
.drawer-avatar-img {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e2e8f0;
}
.drawer-avatar-placeholder {
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1e40af);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 700;
}
.drawer-serial {
position: absolute;
bottom: 0;
right: calc(50% - 60px);
background: #2563eb;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
}
.drawer-name {
text-align: center;
font-size: 18px;
font-weight: 700;
color: #2d3748;
margin-bottom: 4px;
}
.drawer-post {
text-align: center;
font-size: 13px;
color: #718096;
margin-bottom: 8px;
}
.drawer-vote-count {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
border-radius: 20px;
padding: 4px 12px;
font-size: 13px;
font-weight: 700;
margin: 0 auto 14px;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.drawer-meta-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
color: #718096;
margin-bottom: 20px;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
color: #a0aec0;
}
}
.drawer-section {
margin-bottom: 20px;
}
.drawer-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #f0f0f0;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
}
.symbol-preview-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
.symbol-seal-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 144px;
height: 144px;
border-radius: 50%;
background: linear-gradient(135deg, #fef3c7, #fff);
border: 4px solid #f59e0b;
box-shadow:
0 0 0 6px #fef9e7,
0 8px 28px rgb(245 158 11 / 30%);
}
.symbol-preview-img {
width: 110px;
height: 110px;
object-fit: contain;
border-radius: 50%;
background: #fff;
padding: 10px;
}
.symbol-seal-label {
position: absolute;
bottom: -1px;
background: #f59e0b;
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
padding: 2px 10px;
border-radius: 10px;
}
.symbol-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #a0aec0;
padding: 12px 0;
width: 144px;
height: 144px;
border-radius: 50%;
border: 3px dashed #e2e8f0;
justify-content: center;
mat-icon {
font-size: 36px;
height: 36px;
width: 36px;
}
span {
font-size: 11px;
}
}
}
.btn-upload-symbol {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
background: #2563eb;
color: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
background: #1d4ed8;
}
&.uploading {
background: #93c5fd;
pointer-events: none;
}
}
.drawer-profile-text {
font-size: 13px;
color: #4a5568;
line-height: 1.6;
margin: 0;
}
.drawer-info-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
.info-label {
color: #718096;
}
.info-value {
font-weight: 500;
color: #2d3748;
&.text-green {
color: #276749;
}
&.text-red {
color: #e53e3e;
}
}
.info-link {
color: #2563eb;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 2px;
font-weight: 500;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
text-decoration: underline;
}
}
}
.btn-remove-full {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
margin-top: 8px;
padding: 10px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
background: #fed7d7;
}
}
// ── Common states ──────────────────────────────────────────────
.loading-state,
.empty-state {
display: flex;
@@ -308,6 +764,8 @@
}
}
// ── Pagination ─────────────────────────────────────────────────
.pagination-bar {
display: flex;
align-items: center;
@@ -12,8 +12,11 @@ import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
import { ImagePreviewDialogComponent } from '../../../cashier/transaction-entry/transaction-entry.component';
@Component({
selector: 'mifosx-candidate-board',
@@ -23,7 +26,8 @@ import { ElectionService } from '../../election.service';
RouterModule,
FormsModule,
MatIconModule,
MatSnackBarModule
MatSnackBarModule,
MatDialogModule
],
templateUrl: './candidate-board.component.html',
styleUrls: ['./candidate-board.component.scss']
@@ -32,6 +36,8 @@ export class CandidateBoardComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
electionId!: number;
election: any = null;
@@ -49,6 +55,15 @@ export class CandidateBoardComponent implements OnInit {
48
];
selectedCandidate: any = null;
symbolUploading: Record<number, boolean> = {};
candidatePhotoMap: Record<number, SafeResourceUrl | null> = {};
candidatePhotoRaw: Record<number, string> = {};
symbolCache: Record<number, SafeResourceUrl | null> = {};
symbolRaw: Record<number, string> = {};
voteCounts: Record<number, number> = {};
electionStatus = '';
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadElection();
@@ -56,7 +71,13 @@ export class CandidateBoardComponent implements OnInit {
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
this.service.getElection(this.electionId).subscribe((e) => {
this.election = e;
this.electionStatus = e?.status ?? '';
if (this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED') {
this.loadVoteCounts();
}
});
}
loadCandidates(): void {
@@ -78,9 +99,129 @@ export class CandidateBoardComponent implements OnInit {
this.candidates = resp.content ?? [];
this.totalElements = resp.totalElements ?? 0;
this.loading = false;
this.loadCandidatePhotos();
this.loadSymbols();
});
}
openDetail(candidate: any): void {
this.selectedCandidate = candidate;
}
closeDetail(): void {
this.selectedCandidate = null;
}
onSymbolFileChange(candidate: any, event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
input.value = '';
this.symbolUploading[candidate.id] = true;
this.service.uploadCandidateSymbol(this.electionId, candidate.id, file).subscribe({
next: () => {
this.service.getCandidateSymbol(this.electionId, candidate.id).subscribe((base64) => {
candidate.hasSymbol = true;
if (base64) {
this.symbolRaw[candidate.id] = base64;
this.symbolCache[candidate.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
}
if (this.selectedCandidate?.id === candidate.id) {
this.selectedCandidate = { ...this.selectedCandidate, hasSymbol: true };
}
this.symbolUploading[candidate.id] = false;
this.snackBar.open('Symbol uploaded successfully', 'Close', { duration: 3000 });
});
},
error: () => {
this.symbolUploading[candidate.id] = false;
this.snackBar.open('Symbol upload failed', 'Close', { duration: 3000 });
}
});
}
private loadCandidatePhotos(): void {
for (const c of this.candidates) {
if (this.candidatePhotoMap[c.id] !== undefined) continue;
const clientId: number | null = c.memberId ?? null;
if (!clientId) {
this.candidatePhotoMap[c.id] = null;
continue;
}
this.service.getClientImage(clientId).subscribe((base64) => {
if (base64) {
this.candidatePhotoRaw[c.id] = base64;
this.candidatePhotoMap[c.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.candidatePhotoMap[c.id] = null;
}
});
}
}
getCandidatePhoto(candidate: any): SafeResourceUrl | null {
return this.candidatePhotoMap[candidate.id] ?? null;
}
private loadSymbols(): void {
for (const c of this.candidates) {
if (!c.hasSymbol) continue;
if (this.symbolCache[c.id] !== undefined) continue;
this.service.getCandidateSymbol(this.electionId, c.id).subscribe((base64) => {
if (base64) {
this.symbolRaw[c.id] = base64;
this.symbolCache[c.id] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.symbolCache[c.id] = null;
}
});
}
}
getSymbol(candidate: any): SafeResourceUrl | null {
return this.symbolCache[candidate.id] ?? null;
}
openPhotoViewer(candidate: any, event: MouseEvent): void {
event.stopPropagation();
const url = this.candidatePhotoRaw[candidate.id];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: candidate.candidateName },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
openSymbolViewer(candidate: any, event: MouseEvent): void {
event.stopPropagation();
const url = this.symbolRaw[candidate.id];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: 'Election Symbol — ' + candidate.candidateName },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
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;
}
});
}
getVoteCount(candidate: any): number {
return this.voteCounts[candidate.id] ?? 0;
}
get showVoteCounts(): boolean {
return this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED';
}
isNumber(p: number | string): boolean {
return typeof p === 'number';
}
@@ -89,6 +230,7 @@ export class CandidateBoardComponent implements OnInit {
if (!confirm(`Remove ${candidate.candidateName} from the candidate list?`)) return;
this.service.removeCandidate(this.electionId, candidate.id, 'Removed by admin').subscribe(() => {
this.snackBar.open('Candidate removed', 'Close', { duration: 3000 });
if (this.selectedCandidate?.id === candidate.id) this.closeDetail();
this.loadCandidates();
});
}
@@ -96,7 +238,6 @@ export class CandidateBoardComponent implements OnInit {
get posts(): any[] {
return this.election?.posts ?? [];
}
get filteredCandidates(): any[] {
return this.candidates;
}
@@ -28,7 +28,7 @@
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ totalElements }}</span>
<span class="stat-label">Total Elections</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ countByStatus('ACTIVE') }}</span>
@@ -81,6 +81,16 @@
@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">
@@ -7,17 +7,17 @@
*/
.election-dashboard {
padding: 24px;
padding: 16px 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
margin-bottom: 14px;
.page-title {
font-size: 24px;
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0;
@@ -25,101 +25,105 @@
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
display: flex;
gap: 10px;
margin-bottom: 14px;
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 10px 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 7%);
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
.stat-value {
font-size: 32px;
font-size: 22px;
font-weight: 700;
color: #2563eb;
line-height: 1;
}
.stat-label {
font-size: 13px;
font-size: 12px;
color: #718096;
margin-top: 4px;
}
}
}
.elections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.election-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
border-radius: 10px;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
overflow: hidden;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
box-shadow: 0 3px 10px rgb(0 0 0 / 11%);
}
.card-header {
padding: 20px 20px 16px;
padding: 12px 14px 10px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #f0f0f0;
.election-title {
font-size: 16px;
font-size: 14px;
font-weight: 600;
margin: 0 0 4px;
margin: 0 0 2px;
color: #1a202c;
}
.election-type {
font-size: 12px;
font-size: 11px;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.4px;
}
}
.card-body {
padding: 16px 20px;
padding: 10px 14px;
.date-row {
display: flex;
gap: 20px;
gap: 14px;
.date-item {
display: flex;
align-items: flex-start;
gap: 8px;
gap: 6px;
mat-icon {
color: #a0aec0;
font-size: 18px;
margin-top: 2px;
font-size: 16px;
height: 16px;
width: 16px;
margin-top: 1px;
}
.date-label {
display: block;
font-size: 11px;
font-size: 10px;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.date-value {
display: block;
font-size: 13px;
font-size: 12px;
color: #4a5568;
font-weight: 500;
}
@@ -127,26 +131,68 @@
}
.description {
margin-top: 12px;
font-size: 13px;
margin-top: 8px;
font-size: 12px;
color: #718096;
line-height: 1.5;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.voter-count-row {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.voter-count-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
border-radius: 20px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vote-total-chip {
display: inline-flex;
align-items: center;
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
border-radius: 20px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
}
}
.card-actions {
padding: 12px 20px 16px;
padding: 8px 14px 10px;
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
border-top: 1px solid #f7f7f7;
}
}
.status-chip {
padding: 4px 12px;
padding: 3px 9px;
border-radius: 20px;
font-size: 12px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
@@ -192,10 +238,10 @@
.btn-results {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
gap: 3px;
padding: 5px 11px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
@@ -203,9 +249,9 @@
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 15px;
height: 15px;
width: 15px;
}
}
@@ -268,19 +314,19 @@
.btn-warn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
gap: 3px;
padding: 5px 11px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 15px;
height: 15px;
width: 15px;
}
&:disabled {
@@ -295,26 +341,26 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
padding: 60px 20px;
color: #a0aec0;
text-align: center;
.empty-icon {
font-size: 64px;
height: 64px;
width: 64px;
margin-bottom: 16px;
font-size: 52px;
height: 52px;
width: 52px;
margin-bottom: 12px;
}
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 16px;
margin-bottom: 12px;
}
@keyframes spin {
@@ -328,16 +374,16 @@
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
padding: 12px 16px;
gap: 8px;
margin-top: 14px;
padding: 8px 12px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(0 0 0 / 7%);
}
.pg-info {
font-size: 13px;
font-size: 12px;
color: #718096;
white-space: nowrap;
}
@@ -345,21 +391,21 @@
.pg-controls {
display: flex;
align-items: center;
gap: 4px;
gap: 3px;
}
.pg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 6px;
min-width: 30px;
height: 30px;
padding: 0 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
border-radius: 6px;
background: #fff;
color: #4a5568;
font-size: 13px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
@@ -367,9 +413,9 @@
border-color 0.15s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover:not(:disabled) {
@@ -398,18 +444,18 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
min-width: 30px;
height: 30px;
color: #a0aec0;
font-size: 14px;
font-size: 13px;
}
.pg-size {
select {
padding: 6px 10px;
padding: 5px 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
border-radius: 6px;
font-size: 12px;
color: #4a5568;
outline: none;
cursor: pointer;
@@ -36,6 +36,7 @@ export class ElectionDashboardComponent implements OnInit {
totalElements = 0;
statusCounts: Record<string, number> = {};
loading = true;
voteSummaryMap: Record<number, any> = {};
pageSize = 6;
currentPage = 1;
@@ -80,9 +81,20 @@ export class ElectionDashboardComponent implements OnInit {
this.totalElements = resp.totalElements ?? 0;
this.statusCounts = resp.statusCounts ?? {};
this.loading = false;
this.loadVoteSummaries();
});
}
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;
});
}
}
}
statusLabel(status: string): string {
return this.statusConfig[status]?.label ?? status;
}
@@ -803,7 +803,20 @@
<span class="nd-candidate-id">#{{ nomination.candidateId }}</span>
</div>
}
@if (!canRunEligibility && !canRunCeoCheck && !canGenerateNDC && !canApproveStep && !canApproveCandidate) {
@if (canWithdraw) {
<button class="nd-btn nd-btn--red" (click)="withdraw()" [disabled]="actionLoading">
<mat-icon>undo</mat-icon>Withdraw Nomination
</button>
}
@if (
!canResubmit &&
!canRunEligibility &&
!canRunCeoCheck &&
!canGenerateNDC &&
!canApproveStep &&
!canApproveCandidate &&
!canWithdraw
) {
<div class="nd-empty-row"><mat-icon>info_outline</mat-icon>No actions available.</div>
}
</div>
@@ -390,4 +390,31 @@ export class NominationDetailComponent implements OnInit {
get isAlreadyCandidate(): boolean {
return this.nomination?.status === 'APPROVED' && !!this.nomination?.candidateId;
}
get canWithdraw(): boolean {
return ![
'APPROVED',
'WITHDRAWN'
].includes(this.nomination?.status);
}
withdraw(): void {
if (!confirm('Are you sure you want to withdraw this nomination? This action cannot be undone.')) return;
this.actionLoading = true;
this.service
.withdrawNomination(this.electionId, this.nominationId)
.pipe(
catchError(() => {
this.snackBar.open('Withdrawal failed', 'Close', { duration: 3000 });
this.actionLoading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res === null) return;
this.actionLoading = false;
this.load();
this.snackBar.open('Nomination withdrawn successfully', 'Close', { duration: 3000 });
});
}
}
@@ -83,7 +83,20 @@
</span>
</td>
<td>
<a [routerLink]="['/election', electionId, 'nomination', nom.id]" class="btn-view">View</a>
<div class="td-actions">
<a [routerLink]="['/election', electionId, 'nomination', nom.id]" class="btn-view">View</a>
@if (canWithdrawNom(nom)) {
<button
class="btn-withdraw"
(click)="withdraw(nom)"
[disabled]="withdrawingId === nom.id"
title="Withdraw nomination"
>
<mat-icon>undo</mat-icon>
{{ withdrawingId === nom.id ? '...' : 'Withdraw' }}
</button>
}
</div>
</td>
</tr>
}
@@ -215,6 +215,13 @@
}
}
.td-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.btn-view {
display: inline-block;
padding: 5px 14px;
@@ -230,6 +237,36 @@
}
}
.btn-withdraw {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 5px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&:hover:not(:disabled) {
background: #fed7d7;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.loading-state,
.empty-state {
display: flex;
@@ -42,6 +42,7 @@ export class NominationListComponent implements OnInit {
summaryRejected = 0;
loading = true;
filterStatus = '';
withdrawingId: number | null = null;
pageSize = 10;
currentPage = 1;
@@ -146,6 +147,33 @@ export class NominationListComponent implements OnInit {
this.fetchPage();
}
canWithdrawNom(nom: any): boolean {
return ![
'APPROVED',
'WITHDRAWN'
].includes(nom.status);
}
withdraw(nom: any): void {
if (!confirm(`Withdraw nomination for "${nom.applicantName}"?`)) return;
this.withdrawingId = nom.id;
this.service
.withdrawNomination(this.electionId, nom.id)
.pipe(
catchError(() => {
this.snackBar.open('Withdrawal failed', 'Close', { duration: 3000 });
this.withdrawingId = null;
return of(null);
})
)
.subscribe((res: any) => {
this.withdrawingId = null;
if (res === null) return;
this.snackBar.open('Nomination withdrawn', 'Close', { duration: 3000 });
this.load();
});
}
get summary() {
return {
total: this.totalElements,
@@ -72,8 +72,8 @@
</div>
<div class="ballot-avatar">
@if (candidate.photoUrl) {
<img [src]="candidate.photoUrl" [alt]="candidate.candidateName" />
@if (getCandidatePhoto(candidate)) {
<img [src]="getCandidatePhoto(candidate)" [alt]="candidate.candidateName" />
} @else {
<div class="avatar-initial">{{ candidate.candidateName.charAt(0) }}</div>
}
@@ -82,8 +82,8 @@
<div class="ballot-info">
<span class="serial">No. {{ candidate.serialNumber }}</span>
<h4>{{ candidate.candidateName }}</h4>
@if (candidate.symbol) {
<span class="symbol-label">{{ candidate.symbol }}</span>
@if (getSymbol(candidate)) {
<img [src]="getSymbol(candidate)" class="ballot-symbol" alt="Symbol" title="Election Symbol" />
}
</div>
@@ -244,9 +244,13 @@
margin: 0 0 4px;
}
.symbol-label {
font-size: 12px;
color: #718096;
.ballot-symbol {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: contain;
border: 2px solid #f59e0b;
margin-top: 6px;
}
}
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute, Router } 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';
@@ -31,6 +32,7 @@ export class VotingInterfaceComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
electionId!: number;
election: any = null;
@@ -42,6 +44,9 @@ export class VotingInterfaceComponent implements OnInit {
selections: Record<number, number> = {};
votedPosts: Set<number> = new Set();
candidatePhotoMap: Record<number, SafeResourceUrl | null> = {};
symbolCache: Record<number, SafeResourceUrl | null> = {};
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadData();
@@ -59,8 +64,10 @@ export class VotingInterfaceComponent implements OnInit {
candidates
]: any[]) => {
this.election = election;
this.candidates = candidates ?? [];
this.candidates = (candidates as any)?.content ?? [];
this.loading = false;
this.loadCandidatePhotos();
this.loadSymbols();
}
)
.catch(() => {
@@ -68,6 +75,37 @@ export class VotingInterfaceComponent implements OnInit {
});
}
private loadCandidatePhotos(): void {
for (const c of this.candidates) {
if (this.candidatePhotoMap[c.id] !== undefined) continue;
if (!c.memberId) {
this.candidatePhotoMap[c.id] = null;
continue;
}
this.service.getClientImage(c.memberId).subscribe((base64) => {
this.candidatePhotoMap[c.id] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
});
}
}
private loadSymbols(): void {
for (const c of this.candidates) {
if (!c.hasSymbol) continue;
if (this.symbolCache[c.id] !== undefined) continue;
this.service.getCandidateSymbol(this.electionId, c.id).subscribe((base64) => {
this.symbolCache[c.id] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
});
}
}
getCandidatePhoto(candidate: any): SafeResourceUrl | null {
return this.candidatePhotoMap[candidate.id] ?? null;
}
getSymbol(candidate: any): SafeResourceUrl | null {
return this.symbolCache[candidate.id] ?? null;
}
groupByPost(): { postId: number; postName: string; candidates: any[] }[] {
const grouped: Record<string, { postId: number; postName: string; candidates: any[] }> = {};
for (const c of this.candidates) {
+31
View File
@@ -9,6 +9,8 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ElectionService {
@@ -107,6 +109,31 @@ export class ElectionService {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/approve-candidate`, {});
}
updateCandidate(
electionId: number,
candidateId: number,
data: { symbol?: string; briefProfile?: string }
): Observable<any> {
return this.http.patch(`/election/${electionId}/candidate/${candidateId}`, data);
}
uploadCandidateSymbol(electionId: number, candidateId: number, file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
return this.http.post(`/election/${electionId}/candidate/${candidateId}/symbol`, formData);
}
getCandidateSymbol(electionId: number, candidateId: number): Observable<string | null> {
return this.http
.get(`/election/${electionId}/candidate/${candidateId}/symbol`, { responseType: 'text' })
.pipe(catchError(() => of(null)));
}
getClientImage(clientId: number): Observable<string | null> {
return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
}
removeCandidate(electionId: number, candidateId: number, reason?: string): Observable<any> {
let params = new HttpParams();
if (reason) params = params.set('reason', reason);
@@ -125,4 +152,8 @@ export class ElectionService {
getResults(electionId: number): Observable<any[]> {
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)));
}
}
+11
View File
@@ -46,6 +46,17 @@
color: #fecaca !important;
}
// ===== Image Preview Dialog (election candidate photo/symbol viewer) =====
.ipd-dialog .mat-mdc-dialog-container {
padding: 0;
border-radius: 10px;
overflow: hidden;
}
.ipd-backdrop {
background: rgb(0 0 0 / 75%);
}
// ===== Sub-menu Mega Panels (rendered in CDK overlay) =====
.deposits-mega-panel,
.hrm-mega-panel {