diff --git a/src/app/election/components/candidate-board/candidate-board.component.html b/src/app/election/components/candidate-board/candidate-board.component.html index 88d4a6142..5c7f11907 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.html +++ b/src/app/election/components/candidate-board/candidate-board.component.html @@ -101,13 +101,18 @@ } @else { No symbol } - + {{ symbolUploading[candidate.id] ? 'hourglass_top' : 'upload' }} {{ candidate.hasSymbol ? 'Change' : 'Upload' }} @@ -119,10 +124,12 @@ @if (!candidate.isActive) { Removed - } @else { + } @else if (canModifyCandidates) { person_remove Remove + } @else { + lock Locked } @@ -235,19 +242,28 @@ No symbol yet } - - {{ symbolUploading[selectedCandidate.id] ? 'hourglass_top' : 'upload_file' }} + + {{ + symbolUploading[selectedCandidate.id] ? 'hourglass_top' : !canModifyCandidates ? 'lock' : 'upload_file' + }} {{ symbolUploading[selectedCandidate.id] ? 'Uploading...' - : selectedCandidate.hasSymbol - ? 'Change Symbol' - : 'Upload Symbol' + : !canModifyCandidates + ? 'Symbol Locked' + : selectedCandidate.hasSymbol + ? 'Change Symbol' + : 'Upload Symbol' }} @@ -297,10 +313,15 @@ - @if (selectedCandidate.isActive) { + @if (selectedCandidate.isActive && canModifyCandidates) { person_remove Remove Candidate + } @else if (selectedCandidate.isActive && !canModifyCandidates) { + + lock + Candidate cannot be removed after voting has started + } 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 9c8c7db47..007620bf5 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.scss +++ b/src/app/election/components/candidate-board/candidate-board.component.scss @@ -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; 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 98e34fde2..41b0ab44a 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.ts +++ b/src/app/election/components/candidate-board/candidate-board.component.ts @@ -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'; } diff --git a/src/app/election/components/director-board/director-board.component.html b/src/app/election/components/director-board/director-board.component.html index 13b37a547..faea4e2a4 100644 --- a/src/app/election/components/director-board/director-board.component.html +++ b/src/app/election/components/director-board/director-board.component.html @@ -82,92 +82,141 @@ } @else { - - - @for (d of directors; track d.id) { - - - {{ d.candidateName?.charAt(0) }} - - {{ d.candidateName }} - {{ d.postName }} - - - {{ d.status }} - + + @for (group of electionGroups; track group.electionId) { + + + + + how_to_vote - - - badge - {{ d.designation || '—' }} - - - - - event_available - - Tenure Start - {{ d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—' }} - + + + {{ group.electionTitle }} + @if (group.isCurrent) { + verified Current + } @else { + Past + } - → - - event_busy - - Tenure End - - {{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }} - @if (d.isExpired) { - Expired - } - - - - - - - @if (d.resolutionNo) { - description{{ d.resolutionNo }} - } - @if (d.appointmentDate) { - calendar_today{{ d.appointmentDate | date: 'dd MMM yyyy' }} + @if (group.tenureStart || group.tenureEnd) { + + date_range + {{ group.tenureStart ? (group.tenureStart | date: 'dd MMM yyyy') : '—' }} + → + {{ group.tenureEnd ? (group.tenureEnd | date: 'dd MMM yyyy') : '—' }} + } - - @if (d.remarks) { - {{ d.remarks }} - } - - - edit Edit - - person_remove - {{ deleting === d.id ? 'Removing...' : 'Remove' }} - - + + {{ group.directors.length }} member{{ group.directors.length !== 1 ? 's' : '' }} + + expand_more - } - - @if (totalPages > 1) { - - - chevron_left - - Page {{ page + 1 }} of {{ totalPages }} · {{ totalElements }} records - = totalPages - 1"> - chevron_right - + + @if (group.open) { + + @for (d of group.showAll ? group.directors : group.directors.slice(0, 6); track d.id) { + + + + @if (getDirectorPhoto(d)) { + + } @else { + {{ d.candidateName?.charAt(0) }} + } + + + {{ d.candidateName }} + {{ d.postName }} + + + {{ d.status }} + + + + + badge + {{ d.designation || '—' }} + + + + + event_available + + Tenure Start + {{ + d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—' + }} + + + → + + event_busy + + Tenure End + + {{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }} + @if (d.isExpired) { + Expired + } + + + + + + + @if (d.resolutionNo) { + description{{ d.resolutionNo }} + } + @if (d.appointmentDate) { + calendar_today{{ d.appointmentDate | date: 'dd MMM yyyy' }} + } + + + @if (d.remarks) { + {{ d.remarks }} + } + + + edit Edit + + person_remove + {{ deleting === d.id ? 'Removing...' : 'Remove' }} + + + + } + + + @if (group.directors.length > 6) { + + @if (!group.showAll) { + expand_more + Show {{ group.directors.length - 6 }} more member{{ group.directors.length - 6 !== 1 ? 's' : '' }} + } @else { + expand_less + Show less + } + + } + } } } diff --git a/src/app/election/components/director-board/director-board.component.scss b/src/app/election/components/director-board/director-board.component.scss index 337c6cc50..4ddad67a6 100644 --- a/src/app/election/components/director-board/director-board.component.scss +++ b/src/app/election/components/director-board/director-board.component.scss @@ -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 { diff --git a/src/app/election/components/director-board/director-board.component.ts b/src/app/election/components/director-board/director-board.component.ts index 8a7204990..b4b9a2760 100644 --- a/src/app/election/components/director-board/director-board.component.ts +++ b/src/app/election/components/director-board/director-board.component.ts @@ -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 = {}; // ── 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(); + 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();
{{ d.remarks }}