feat(election): candidate lock UI, director board grouping, collapsible elections, and member photos

- Candidate board: lock Remove and Change Symbol buttons when voting is active (VOTING_OPEN/CLOSED/RESULT_DECLARED/CANCELLED)
- Director board: group directors by election with Current/Past badges, sorted newest first
- Past election groups collapsed by default; click header to expand/collapse with chevron animation
- Show first 6 directors, expand button for remaining members
- Load member photos in director cards via clientImage API (fallback to initials)
- 4-column responsive grid for director cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-03 13:44:05 +05:30
co-authored by Claude Sonnet 4.6
parent 228df2f32c
commit c4387c5c79
6 changed files with 482 additions and 93 deletions
@@ -101,13 +101,18 @@
} @else { } @else {
<span class="no-symbol-text">No symbol</span> <span class="no-symbol-text">No symbol</span>
} }
<label class="btn-symbol-upload" [class.uploading]="symbolUploading[candidate.id]"> <label
class="btn-symbol-upload"
[class.uploading]="symbolUploading[candidate.id]"
[class.btn-locked]="!canModifyCandidates"
>
<mat-icon>{{ symbolUploading[candidate.id] ? 'hourglass_top' : 'upload' }}</mat-icon> <mat-icon>{{ symbolUploading[candidate.id] ? 'hourglass_top' : 'upload' }}</mat-icon>
{{ candidate.hasSymbol ? 'Change' : 'Upload' }} {{ candidate.hasSymbol ? 'Change' : 'Upload' }}
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
style="display: none" style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(candidate, $event)" (change)="onSymbolFileChange(candidate, $event)"
/> />
</label> </label>
@@ -119,10 +124,12 @@
</a> </a>
@if (!candidate.isActive) { @if (!candidate.isActive) {
<span class="removed-badge">Removed</span> <span class="removed-badge">Removed</span>
} @else { } @else if (canModifyCandidates) {
<button class="btn-remove" (click)="removeCandidate(candidate)"> <button class="btn-remove" (click)="removeCandidate(candidate)">
<mat-icon>person_remove</mat-icon> Remove <mat-icon>person_remove</mat-icon> Remove
</button> </button>
} @else {
<span class="locked-badge"><mat-icon>lock</mat-icon> Locked</span>
} }
</div> </div>
</div> </div>
@@ -235,19 +242,28 @@
<span>No symbol yet</span> <span>No symbol yet</span>
</div> </div>
} }
<label class="btn-upload-symbol" [class.uploading]="symbolUploading[selectedCandidate.id]"> <label
<mat-icon>{{ symbolUploading[selectedCandidate.id] ? 'hourglass_top' : 'upload_file' }}</mat-icon> class="btn-upload-symbol"
[class.uploading]="symbolUploading[selectedCandidate.id]"
[class.btn-locked]="!canModifyCandidates"
>
<mat-icon>{{
symbolUploading[selectedCandidate.id] ? 'hourglass_top' : !canModifyCandidates ? 'lock' : 'upload_file'
}}</mat-icon>
{{ {{
symbolUploading[selectedCandidate.id] symbolUploading[selectedCandidate.id]
? 'Uploading...' ? 'Uploading...'
: selectedCandidate.hasSymbol : !canModifyCandidates
? 'Change Symbol' ? 'Symbol Locked'
: 'Upload Symbol' : selectedCandidate.hasSymbol
? 'Change Symbol'
: 'Upload Symbol'
}} }}
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
style="display: none" style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(selectedCandidate, $event)" (change)="onSymbolFileChange(selectedCandidate, $event)"
/> />
</label> </label>
@@ -297,10 +313,15 @@
</div> </div>
</div> </div>
@if (selectedCandidate.isActive) { @if (selectedCandidate.isActive && canModifyCandidates) {
<button class="btn-remove-full" (click)="removeCandidate(selectedCandidate)"> <button class="btn-remove-full" (click)="removeCandidate(selectedCandidate)">
<mat-icon>person_remove</mat-icon> Remove Candidate <mat-icon>person_remove</mat-icon> Remove Candidate
</button> </button>
} @else if (selectedCandidate.isActive && !canModifyCandidates) {
<div class="locked-notice">
<mat-icon>lock</mat-icon>
Candidate cannot be removed after voting has started
</div>
} }
</div> </div>
</div> </div>
@@ -375,6 +375,31 @@
border-radius: 6px; border-radius: 6px;
font-size: 11px; font-size: 11px;
} }
.locked-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
.btn-locked {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed !important;
} }
// ── Candidate Detail Drawer ──────────────────────────────────── // ── Candidate Detail Drawer ────────────────────────────────────
@@ -734,6 +759,28 @@
} }
} }
.locked-notice {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 8px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
flex-shrink: 0;
}
}
.btn-remove-full { .btn-remove-full {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -217,6 +217,16 @@ export class CandidateBoardComponent implements OnInit {
return this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED'; return this.electionStatus === 'VOTING_OPEN' || this.electionStatus === 'VOTING_CLOSED';
} }
get canModifyCandidates(): boolean {
const locked = [
'VOTING_OPEN',
'VOTING_CLOSED',
'RESULT_DECLARED',
'CANCELLED'
];
return !locked.includes(this.electionStatus);
}
isNumber(p: number | string): boolean { isNumber(p: number | string): boolean {
return typeof p === 'number'; return typeof p === 'number';
} }
@@ -82,92 +82,141 @@
</button> </button>
</div> </div>
} @else { } @else {
<!-- Director Cards Grid --> <!-- Grouped by Election -->
<div class="directors-grid"> @for (group of electionGroups; track group.electionId) {
@for (d of directors; track d.id) { <div class="election-group" [class.past-group]="!group.isCurrent">
<div class="director-card"> <!-- Election Group Header (clickable) -->
<div class="card-top"> <div
<div class="director-avatar">{{ d.candidateName?.charAt(0) }}</div> class="election-group-header"
<div class="director-info"> [class.current-header]="group.isCurrent"
<h4>{{ d.candidateName }}</h4> [class.header-open]="group.open"
<span class="post-badge">{{ d.postName }}</span> (click)="group.open = !group.open"
</div> >
<span <div class="election-group-icon" [class.past-icon]="!group.isCurrent">
class="status-chip" <mat-icon>how_to_vote</mat-icon>
[style.background-color]="statusColor(d.status) + '20'"
[style.color]="statusColor(d.status)"
>
{{ d.status }}
</span>
</div> </div>
<div class="election-group-info">
<div class="card-field designation-field"> <div class="election-title-row">
<mat-icon>badge</mat-icon> <h3>{{ group.electionTitle }}</h3>
<span>{{ d.designation || '—' }}</span> @if (group.isCurrent) {
</div> <span class="current-badge"><mat-icon>verified</mat-icon> Current</span>
} @else {
<div class="tenure-row" [class.tenure-expired]="d.isExpired"> <span class="past-badge">Past</span>
<div class="card-field"> }
<mat-icon>event_available</mat-icon>
<div>
<span class="field-label">Tenure Start</span>
<span class="field-val">{{ d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—' }}</span>
</div>
</div> </div>
<div class="tenure-divider">→</div> @if (group.tenureStart || group.tenureEnd) {
<div class="card-field"> <span class="election-tenure-range">
<mat-icon>event_busy</mat-icon> <mat-icon>date_range</mat-icon>
<div> {{ group.tenureStart ? (group.tenureStart | date: 'dd MMM yyyy') : '—' }}
<span class="field-label">Tenure End</span> &nbsp;→&nbsp;
<span class="field-val" [class.expired-date]="d.isExpired"> {{ group.tenureEnd ? (group.tenureEnd | date: 'dd MMM yyyy') : '—' }}
{{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }} </span>
@if (d.isExpired) {
<span class="expired-tag">Expired</span>
}
</span>
</div>
</div>
</div>
<div class="meta-row">
@if (d.resolutionNo) {
<span class="meta-tag"><mat-icon>description</mat-icon>{{ d.resolutionNo }}</span>
}
@if (d.appointmentDate) {
<span class="meta-tag"
><mat-icon>calendar_today</mat-icon>{{ d.appointmentDate | date: 'dd MMM yyyy' }}</span
>
} }
</div> </div>
<span class="election-member-count" [class.past-count]="!group.isCurrent">
@if (d.remarks) { {{ group.directors.length }} member{{ group.directors.length !== 1 ? 's' : '' }}
<p class="remarks-text">{{ d.remarks }}</p> </span>
} <mat-icon class="group-chevron" [class.chevron-open]="group.open">expand_more</mat-icon>
<div class="card-actions">
<button class="btn-edit" (click)="openEdit(d)"><mat-icon>edit</mat-icon> Edit</button>
<button
class="btn-delete"
(click)="confirmRemove(d)"
[disabled]="deleting === d.id || d.status === 'REMOVED'"
>
<mat-icon>person_remove</mat-icon>
{{ deleting === d.id ? 'Removing...' : 'Remove' }}
</button>
</div>
</div> </div>
}
</div>
@if (totalPages > 1) { <!-- Director Cards -->
<div class="pagination"> @if (group.open) {
<button class="page-btn" (click)="prevPage()" [disabled]="page === 0"> <div class="directors-grid">
<mat-icon>chevron_left</mat-icon> @for (d of group.showAll ? group.directors : group.directors.slice(0, 6); track d.id) {
</button> <div class="director-card">
<span class="page-info">Page {{ page + 1 }} of {{ totalPages }} &nbsp;·&nbsp; {{ totalElements }} records</span> <div class="card-top">
<button class="page-btn" (click)="nextPage()" [disabled]="page >= totalPages - 1"> <div class="director-avatar">
<mat-icon>chevron_right</mat-icon> @if (getDirectorPhoto(d)) {
</button> <img [src]="getDirectorPhoto(d)" [alt]="d.candidateName" class="director-avatar-img" />
} @else {
{{ d.candidateName?.charAt(0) }}
}
</div>
<div class="director-info">
<h4>{{ d.candidateName }}</h4>
<span class="post-badge">{{ d.postName }}</span>
</div>
<span
class="status-chip"
[style.background-color]="statusColor(d.status) + '20'"
[style.color]="statusColor(d.status)"
>
{{ d.status }}
</span>
</div>
<div class="card-field designation-field">
<mat-icon>badge</mat-icon>
<span>{{ d.designation || '—' }}</span>
</div>
<div class="tenure-row" [class.tenure-expired]="d.isExpired">
<div class="card-field">
<mat-icon>event_available</mat-icon>
<div>
<span class="field-label">Tenure Start</span>
<span class="field-val">{{
d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—'
}}</span>
</div>
</div>
<div class="tenure-divider">→</div>
<div class="card-field">
<mat-icon>event_busy</mat-icon>
<div>
<span class="field-label">Tenure End</span>
<span class="field-val" [class.expired-date]="d.isExpired">
{{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }}
@if (d.isExpired) {
<span class="expired-tag">Expired</span>
}
</span>
</div>
</div>
</div>
<div class="meta-row">
@if (d.resolutionNo) {
<span class="meta-tag"><mat-icon>description</mat-icon>{{ d.resolutionNo }}</span>
}
@if (d.appointmentDate) {
<span class="meta-tag"
><mat-icon>calendar_today</mat-icon>{{ d.appointmentDate | date: 'dd MMM yyyy' }}</span
>
}
</div>
@if (d.remarks) {
<p class="remarks-text">{{ d.remarks }}</p>
}
<div class="card-actions">
<button class="btn-edit" (click)="openEdit(d)"><mat-icon>edit</mat-icon> Edit</button>
<button
class="btn-delete"
(click)="confirmRemove(d)"
[disabled]="deleting === d.id || d.status === 'REMOVED'"
>
<mat-icon>person_remove</mat-icon>
{{ deleting === d.id ? 'Removing...' : 'Remove' }}
</button>
</div>
</div>
}
</div>
@if (group.directors.length > 6) {
<button class="expand-btn" (click)="group.showAll = !group.showAll; $event.stopPropagation()">
@if (!group.showAll) {
<mat-icon>expand_more</mat-icon>
Show {{ group.directors.length - 6 }} more member{{ group.directors.length - 6 !== 1 ? 's' : '' }}
} @else {
<mat-icon>expand_less</mat-icon>
Show less
}
</button>
}
}
</div> </div>
} }
} }
@@ -320,11 +320,208 @@ $font:
} }
} }
// ── Election Group ────────────────────────────────────────────────
.election-group {
margin-bottom: 36px;
&.past-group {
opacity: 0.82;
}
}
.election-group-header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: #fff;
border: $border;
border-radius: $radius $radius 0 0;
border-bottom: 3px solid #cbd5e1;
margin-bottom: 0;
cursor: pointer;
user-select: none;
transition: background 0.15s;
&:hover {
background: #f8fafc;
}
&.current-header {
border-bottom-color: $primary;
background: linear-gradient(135deg, #fff 0%, rgb(99 102 241 / 2%) 100%);
&:hover {
background: linear-gradient(135deg, #f8fafc 0%, rgb(99 102 241 / 4%) 100%);
}
}
&:not(.header-open) {
border-radius: $radius;
border-bottom-width: 1px;
border-bottom-color: rgb(226 232 240 / 80%);
}
}
.group-chevron {
font-size: 20px;
height: 20px;
width: 20px;
color: $sub;
flex-shrink: 0;
transition: transform 0.25s ease;
&.chevron-open {
transform: rotate(180deg);
}
}
.election-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
h3 {
font-size: 16px;
font-weight: 700;
color: $text;
margin: 0;
}
}
.current-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 700;
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
color: #065f46;
padding: 2px 8px 2px 5px;
border-radius: 12px;
letter-spacing: 0.2px;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
.past-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
background: #f1f5f9;
color: #94a3b8;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 0.2px;
}
.election-group-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, $primary 0%, #4f46e5 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
mat-icon {
font-size: 22px;
height: 22px;
width: 22px;
color: #fff;
}
&.past-icon {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
}
}
.election-group-info {
flex: 1;
min-width: 0;
}
.election-tenure-range {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: $sub;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.election-member-count {
font-size: 12px;
font-weight: 700;
background: rgb(99 102 241 / 10%);
color: $primary;
padding: 4px 12px;
border-radius: 20px;
flex-shrink: 0;
&.past-count {
background: #f1f5f9;
color: #94a3b8;
}
}
// ── Expand / Collapse ─────────────────────────────────────────────
.expand-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 11px;
background: #fff;
border: $border;
border-top: none;
border-radius: 0 0 $radius $radius;
font-size: 13px;
font-weight: 600;
color: $primary;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: rgb(99 102 241 / 4%);
}
}
// ── Director Cards Grid ─────────────────────────────────────────── // ── Director Cards Grid ───────────────────────────────────────────
.directors-grid { .directors-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px; gap: 14px;
background: #f1f5f9;
border: $border;
border-top: none;
border-radius: 0;
padding: 16px;
}
.election-group:not(:has(.expand-btn)) .directors-grid {
border-radius: 0 0 $radius $radius;
} }
.director-card { .director-card {
@@ -332,10 +529,10 @@ $font:
border-radius: $radius; border-radius: $radius;
border: $border; border: $border;
box-shadow: $shadow; box-shadow: $shadow;
padding: 20px; padding: 14px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 10px;
transition: all 0.25s; transition: all 0.25s;
&:hover { &:hover {
@@ -362,6 +559,14 @@ $font:
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
.director-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
} }
.director-info { .director-info {
@@ -17,6 +17,7 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ElectionService } from '../../election.service'; import { ElectionService } from '../../election.service';
@Component({ @Component({
@@ -41,6 +42,9 @@ import { ElectionService } from '../../election.service';
export class DirectorBoardComponent implements OnInit { export class DirectorBoardComponent implements OnInit {
private service = inject(ElectionService); private service = inject(ElectionService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
directorPhotoMap: Record<number, SafeResourceUrl | null> = {};
// ── Pending appointments ─────────────────────────────────────── // ── Pending appointments ───────────────────────────────────────
pendingCandidates: any[] = []; pendingCandidates: any[] = [];
@@ -48,10 +52,11 @@ export class DirectorBoardComponent implements OnInit {
// ── Director list ────────────────────────────────────────────── // ── Director list ──────────────────────────────────────────────
directors: any[] = []; directors: any[] = [];
electionGroups: any[] = [];
loading = true; loading = true;
totalElements = 0; totalElements = 0;
page = 0; page = 0;
pageSize = 20; pageSize = 100;
filterStatus = ''; filterStatus = '';
statusOptions = [ statusOptions = [
@@ -150,10 +155,13 @@ export class DirectorBoardComponent implements OnInit {
isExpired: d.tenureEndDate ? d.tenureEndDate < todayStr : false isExpired: d.tenureEndDate ? d.tenureEndDate < todayStr : false
})); }));
this.totalElements = data?.totalElements ?? 0; this.totalElements = data?.totalElements ?? 0;
this.electionGroups = this.groupByElection(this.directors);
this.loading = false; this.loading = false;
this.loadDirectorPhotos(this.directors);
}, },
error: () => { error: () => {
this.directors = []; this.directors = [];
this.electionGroups = [];
this.totalElements = 0; this.totalElements = 0;
this.loading = false; this.loading = false;
} }
@@ -165,6 +173,19 @@ export class DirectorBoardComponent implements OnInit {
this.load(); this.load();
} }
private loadDirectorPhotos(directors: any[]): void {
for (const d of directors) {
if (!d.memberId || this.directorPhotoMap[d.id] !== undefined) continue;
this.service.getClientImage(d.memberId).subscribe((base64) => {
this.directorPhotoMap[d.id] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
});
}
}
getDirectorPhoto(d: any): SafeResourceUrl | null {
return this.directorPhotoMap[d.id] ?? null;
}
// ── Open appoint drawer (step 1) ─────────────────────────────── // ── Open appoint drawer (step 1) ───────────────────────────────
openAppoint(): void { openAppoint(): void {
this.submitted = false; this.submitted = false;
@@ -370,6 +391,42 @@ export class DirectorBoardComponent implements OnInit {
return map[status] ?? '#94a3b8'; return map[status] ?? '#94a3b8';
} }
private groupByElection(directors: any[]): any[] {
const todayStr = new Date().toISOString().slice(0, 10);
const map = new Map<number, any>();
for (const d of directors) {
if (!map.has(d.electionId)) {
map.set(d.electionId, {
electionId: d.electionId,
electionTitle: d.electionTitle || `Election #${d.electionId}`,
tenureStart: null,
tenureEnd: null,
isCurrent: false,
directors: []
});
}
const group = map.get(d.electionId);
group.directors.push(d);
if (d.tenureStartDate && (!group.tenureStart || d.tenureStartDate < group.tenureStart)) {
group.tenureStart = d.tenureStartDate;
}
if (d.tenureEndDate && (!group.tenureEnd || d.tenureEndDate > group.tenureEnd)) {
group.tenureEnd = d.tenureEndDate;
}
}
const groups = Array.from(map.values());
for (const g of groups) {
g.isCurrent = g.tenureEnd ? g.tenureEnd >= todayStr : false;
g.open = g.isCurrent;
g.showAll = false;
}
return groups.sort((a, b) => {
const aStart = a.tenureStart ?? '';
const bStart = b.tenureStart ?? '';
return bStart.localeCompare(aStart);
});
}
private formatDate(date: Date | null): string | null { private formatDate(date: Date | null): string | null {
if (!date) return null; if (!date) return null;
const y = date.getFullYear(); const y = date.getFullYear();