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:
co-authored by
Claude Sonnet 4.6
parent
9e33392b01
commit
f1b829a100
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user