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 {
<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>
{{ candidate.hasSymbol ? 'Change' : 'Upload' }}
<input
type="file"
accept="image/*"
style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(candidate, $event)"
/>
</label>
@@ -119,10 +124,12 @@
</a>
@if (!candidate.isActive) {
<span class="removed-badge">Removed</span>
} @else {
} @else if (canModifyCandidates) {
<button class="btn-remove" (click)="removeCandidate(candidate)">
<mat-icon>person_remove</mat-icon> Remove
</button>
} @else {
<span class="locked-badge"><mat-icon>lock</mat-icon> Locked</span>
}
</div>
</div>
@@ -235,19 +242,28 @@
<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>
<label
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]
? 'Uploading...'
: selectedCandidate.hasSymbol
? 'Change Symbol'
: 'Upload Symbol'
: !canModifyCandidates
? 'Symbol Locked'
: selectedCandidate.hasSymbol
? 'Change Symbol'
: 'Upload Symbol'
}}
<input
type="file"
accept="image/*"
style="display: none"
[disabled]="!canModifyCandidates"
(change)="onSymbolFileChange(selectedCandidate, $event)"
/>
</label>
@@ -297,10 +313,15 @@
</div>
</div>
@if (selectedCandidate.isActive) {
@if (selectedCandidate.isActive && canModifyCandidates) {
<button class="btn-remove-full" (click)="removeCandidate(selectedCandidate)">
<mat-icon>person_remove</mat-icon> Remove Candidate
</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>
@@ -375,6 +375,31 @@
border-radius: 6px;
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 ────────────────────────────────────
@@ -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 {
display: flex;
align-items: center;
@@ -217,6 +217,16 @@ export class CandidateBoardComponent implements OnInit {
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 {
return typeof p === 'number';
}
@@ -82,92 +82,141 @@
</button>
</div>
} @else {
<!-- Director Cards Grid -->
<div class="directors-grid">
@for (d of directors; track d.id) {
<div class="director-card">
<div class="card-top">
<div class="director-avatar">{{ 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>
<!-- Grouped by Election -->
@for (group of electionGroups; track group.electionId) {
<div class="election-group" [class.past-group]="!group.isCurrent">
<!-- Election Group Header (clickable) -->
<div
class="election-group-header"
[class.current-header]="group.isCurrent"
[class.header-open]="group.open"
(click)="group.open = !group.open"
>
<div class="election-group-icon" [class.past-icon]="!group.isCurrent">
<mat-icon>how_to_vote</mat-icon>
</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 class="election-group-info">
<div class="election-title-row">
<h3>{{ group.electionTitle }}</h3>
@if (group.isCurrent) {
<span class="current-badge"><mat-icon>verified</mat-icon> Current</span>
} @else {
<span class="past-badge">Past</span>
}
</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
>
@if (group.tenureStart || group.tenureEnd) {
<span class="election-tenure-range">
<mat-icon>date_range</mat-icon>
{{ group.tenureStart ? (group.tenureStart | date: 'dd MMM yyyy') : '—' }}
&nbsp;→&nbsp;
{{ group.tenureEnd ? (group.tenureEnd | 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>
<span class="election-member-count" [class.past-count]="!group.isCurrent">
{{ group.directors.length }} member{{ group.directors.length !== 1 ? 's' : '' }}
</span>
<mat-icon class="group-chevron" [class.chevron-open]="group.open">expand_more</mat-icon>
</div>
}
</div>
@if (totalPages > 1) {
<div class="pagination">
<button class="page-btn" (click)="prevPage()" [disabled]="page === 0">
<mat-icon>chevron_left</mat-icon>
</button>
<span class="page-info">Page {{ page + 1 }} of {{ totalPages }} &nbsp;·&nbsp; {{ totalElements }} records</span>
<button class="page-btn" (click)="nextPage()" [disabled]="page >= totalPages - 1">
<mat-icon>chevron_right</mat-icon>
</button>
<!-- Director Cards -->
@if (group.open) {
<div class="directors-grid">
@for (d of group.showAll ? group.directors : group.directors.slice(0, 6); track d.id) {
<div class="director-card">
<div class="card-top">
<div class="director-avatar">
@if (getDirectorPhoto(d)) {
<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>
}
}
@@ -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 ───────────────────────────────────────────
.directors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
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 {
@@ -332,10 +529,10 @@ $font:
border-radius: $radius;
border: $border;
box-shadow: $shadow;
padding: 20px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
transition: all 0.25s;
&:hover {
@@ -362,6 +559,14 @@ $font:
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.director-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.director-info {
@@ -17,6 +17,7 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ElectionService } from '../../election.service';
@Component({
@@ -41,6 +42,9 @@ import { ElectionService } from '../../election.service';
export class DirectorBoardComponent implements OnInit {
private service = inject(ElectionService);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
directorPhotoMap: Record<number, SafeResourceUrl | null> = {};
// ── Pending appointments ───────────────────────────────────────
pendingCandidates: any[] = [];
@@ -48,10 +52,11 @@ export class DirectorBoardComponent implements OnInit {
// ── Director list ──────────────────────────────────────────────
directors: any[] = [];
electionGroups: any[] = [];
loading = true;
totalElements = 0;
page = 0;
pageSize = 20;
pageSize = 100;
filterStatus = '';
statusOptions = [
@@ -150,10 +155,13 @@ export class DirectorBoardComponent implements OnInit {
isExpired: d.tenureEndDate ? d.tenureEndDate < todayStr : false
}));
this.totalElements = data?.totalElements ?? 0;
this.electionGroups = this.groupByElection(this.directors);
this.loading = false;
this.loadDirectorPhotos(this.directors);
},
error: () => {
this.directors = [];
this.electionGroups = [];
this.totalElements = 0;
this.loading = false;
}
@@ -165,6 +173,19 @@ export class DirectorBoardComponent implements OnInit {
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) ───────────────────────────────
openAppoint(): void {
this.submitted = false;
@@ -370,6 +391,42 @@ export class DirectorBoardComponent implements OnInit {
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 {
if (!date) return null;
const y = date.getFullYear();