- }
- @if (candidate.caste) {
-
- }
- @if (candidate.location) {
-
+
@if (!candidate.isActive) {
Removed
} @else {
@@ -149,3 +167,135 @@
}
}
+
+@if (selectedCandidate) {
+
+
+
+
+
+
+ @if (getCandidatePhoto(selectedCandidate)) {
+
![Click to view]()
+ } @else {
+
+ {{ selectedCandidate.candidateName.charAt(0).toUpperCase() }}
+
+ }
+
#{{ selectedCandidate.serialNumber }}
+
+
+
{{ selectedCandidate.candidateName }}
+
{{ selectedCandidate.postName }}
+
+ @if (showVoteCounts) {
+
+ how_to_vote
+ {{ getVoteCount(selectedCandidate) }} votes received
+
+ }
+
+ @if (selectedCandidate.approvedDate) {
+
+ event_available
+ Approved on {{ selectedCandidate.approvedDate | date: 'dd MMM yyyy, hh:mm a' }}
+
+ }
+
+
+
how_to_vote Election Symbol
+
+ @if (getSymbol(selectedCandidate)) {
+
+
![Election Symbol]()
+
Election Symbol
+
+ } @else {
+
+ how_to_vote
+ No symbol yet
+
+ }
+
+
+
+
+ @if (selectedCandidate.briefProfile) {
+
+
person Brief Profile
+
{{ selectedCandidate.briefProfile }}
+
+ }
+
+
+
info Details
+
+
+ Serial No.
+ #{{ selectedCandidate.serialNumber }}
+
+
+ Post
+ {{ selectedCandidate.postName }}
+
+
+ Status
+
+ {{ selectedCandidate.isActive ? 'Active' : 'Removed' }}
+
+
+ @if (selectedCandidate.nominationId) {
+
+ }
+
+
+
+ @if (selectedCandidate.isActive) {
+
+ }
+
+
+}
diff --git a/src/app/election/components/candidate-board/candidate-board.component.scss b/src/app/election/components/candidate-board/candidate-board.component.scss
index 08df3e793..1cdd972ce 100644
--- a/src/app/election/components/candidate-board/candidate-board.component.scss
+++ b/src/app/election/components/candidate-board/candidate-board.component.scss
@@ -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;
diff --git a/src/app/election/components/candidate-board/candidate-board.component.ts b/src/app/election/components/candidate-board/candidate-board.component.ts
index 74cb897b5..2098f0cc9 100644
--- a/src/app/election/components/candidate-board/candidate-board.component.ts
+++ b/src/app/election/components/candidate-board/candidate-board.component.ts
@@ -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
= {};
+ candidatePhotoMap: Record = {};
+ candidatePhotoRaw: Record = {};
+ symbolCache: Record = {};
+ symbolRaw: Record = {};
+ voteCounts: Record = {};
+ 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;
}
diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.html b/src/app/election/components/election-dashboard/election-dashboard.component.html
index 057cde9ad..74e42e409 100644
--- a/src/app/election/components/election-dashboard/election-dashboard.component.html
+++ b/src/app/election/components/election-dashboard/election-dashboard.component.html
@@ -28,7 +28,7 @@
{{ totalElements }}
- Total Elections
+ Total
{{ countByStatus('ACTIVE') }}
@@ -81,6 +81,16 @@
@if (election.description) {
{{ election.description }}
}
+
+ @if (voteSummaryMap[election.id]) {
+
+
+ how_to_vote
+ {{ voteSummaryMap[election.id].totalVoters }} members voted
+
+ {{ voteSummaryMap[election.id].totalVotes }} total votes
+
+ }
diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.scss b/src/app/election/components/election-dashboard/election-dashboard.component.scss
index 95e7348bc..f0858d664 100644
--- a/src/app/election/components/election-dashboard/election-dashboard.component.scss
+++ b/src/app/election/components/election-dashboard/election-dashboard.component.scss
@@ -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;
diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.ts b/src/app/election/components/election-dashboard/election-dashboard.component.ts
index 5cf0223b1..fd9013d33 100644
--- a/src/app/election/components/election-dashboard/election-dashboard.component.ts
+++ b/src/app/election/components/election-dashboard/election-dashboard.component.ts
@@ -36,6 +36,7 @@ export class ElectionDashboardComponent implements OnInit {
totalElements = 0;
statusCounts: Record = {};
loading = true;
+ voteSummaryMap: Record = {};
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;
}
diff --git a/src/app/election/components/nomination-detail/nomination-detail.component.html b/src/app/election/components/nomination-detail/nomination-detail.component.html
index 57a1068f7..12e38864a 100644
--- a/src/app/election/components/nomination-detail/nomination-detail.component.html
+++ b/src/app/election/components/nomination-detail/nomination-detail.component.html
@@ -803,7 +803,20 @@
#{{ nomination.candidateId }}
}
- @if (!canRunEligibility && !canRunCeoCheck && !canGenerateNDC && !canApproveStep && !canApproveCandidate) {
+ @if (canWithdraw) {
+
+ }
+ @if (
+ !canResubmit &&
+ !canRunEligibility &&
+ !canRunCeoCheck &&
+ !canGenerateNDC &&
+ !canApproveStep &&
+ !canApproveCandidate &&
+ !canWithdraw
+ ) {
info_outlineNo actions available.
}
diff --git a/src/app/election/components/nomination-detail/nomination-detail.component.ts b/src/app/election/components/nomination-detail/nomination-detail.component.ts
index dfd09a5c2..fe8716d9e 100644
--- a/src/app/election/components/nomination-detail/nomination-detail.component.ts
+++ b/src/app/election/components/nomination-detail/nomination-detail.component.ts
@@ -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 });
+ });
+ }
}
diff --git a/src/app/election/components/nomination-list/nomination-list.component.html b/src/app/election/components/nomination-list/nomination-list.component.html
index 81aa3412e..68b0ee6a4 100644
--- a/src/app/election/components/nomination-list/nomination-list.component.html
+++ b/src/app/election/components/nomination-list/nomination-list.component.html
@@ -83,7 +83,20 @@
- View
+
+ View
+ @if (canWithdrawNom(nom)) {
+
+ }
+
|
}
diff --git a/src/app/election/components/nomination-list/nomination-list.component.scss b/src/app/election/components/nomination-list/nomination-list.component.scss
index 975c90cd5..60734985f 100644
--- a/src/app/election/components/nomination-list/nomination-list.component.scss
+++ b/src/app/election/components/nomination-list/nomination-list.component.scss
@@ -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;
diff --git a/src/app/election/components/nomination-list/nomination-list.component.ts b/src/app/election/components/nomination-list/nomination-list.component.ts
index 9cde5a3eb..6dd957982 100644
--- a/src/app/election/components/nomination-list/nomination-list.component.ts
+++ b/src/app/election/components/nomination-list/nomination-list.component.ts
@@ -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,
diff --git a/src/app/election/components/voting-interface/voting-interface.component.html b/src/app/election/components/voting-interface/voting-interface.component.html
index e8cc012b9..b4bc550e9 100644
--- a/src/app/election/components/voting-interface/voting-interface.component.html
+++ b/src/app/election/components/voting-interface/voting-interface.component.html
@@ -72,8 +72,8 @@
- @if (candidate.photoUrl) {
-
![]()
+ @if (getCandidatePhoto(candidate)) {
+
![]()
} @else {
{{ candidate.candidateName.charAt(0) }}
}
@@ -82,8 +82,8 @@
No. {{ candidate.serialNumber }}
{{ candidate.candidateName }}
- @if (candidate.symbol) {
-
{{ candidate.symbol }}
+ @if (getSymbol(candidate)) {
+
![Election Symbol Symbol]()
}
diff --git a/src/app/election/components/voting-interface/voting-interface.component.scss b/src/app/election/components/voting-interface/voting-interface.component.scss
index 281a3132d..1dfdf8fb6 100644
--- a/src/app/election/components/voting-interface/voting-interface.component.scss
+++ b/src/app/election/components/voting-interface/voting-interface.component.scss
@@ -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;
}
}
diff --git a/src/app/election/components/voting-interface/voting-interface.component.ts b/src/app/election/components/voting-interface/voting-interface.component.ts
index 08599c494..ed737fad2 100644
--- a/src/app/election/components/voting-interface/voting-interface.component.ts
+++ b/src/app/election/components/voting-interface/voting-interface.component.ts
@@ -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
= {};
votedPosts: Set = new Set();
+ candidatePhotoMap: Record = {};
+ symbolCache: Record = {};
+
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 = {};
for (const c of this.candidates) {
diff --git a/src/app/election/election.service.ts b/src/app/election/election.service.ts
index 7061696ef..12c75534b 100644
--- a/src/app/election/election.service.ts
+++ b/src/app/election/election.service.ts
@@ -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 {
+ return this.http.patch(`/election/${electionId}/candidate/${candidateId}`, data);
+ }
+
+ uploadCandidateSymbol(electionId: number, candidateId: number, file: File): Observable {
+ 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 {
+ return this.http
+ .get(`/election/${electionId}/candidate/${candidateId}/symbol`, { responseType: 'text' })
+ .pipe(catchError(() => of(null)));
+ }
+
+ getClientImage(clientId: number): Observable {
+ return this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null)));
+ }
+
removeCandidate(electionId: number, candidateId: number, reason?: string): Observable {
let params = new HttpParams();
if (reason) params = params.set('reason', reason);
@@ -125,4 +152,8 @@ export class ElectionService {
getResults(electionId: number): Observable {
return this.http.get(`/election/${electionId}/results`);
}
+
+ getVoteSummary(electionId: number): Observable {
+ return this.http.get(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
+ }
}
diff --git a/src/main.scss b/src/main.scss
index 6e62f1789..8f7260661 100644
--- a/src/main.scss
+++ b/src/main.scss
@@ -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 {