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:
co-authored by
Claude Sonnet 4.6
parent
228df2f32c
commit
c4387c5c79
@@ -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') : '—' }}
|
||||
→
|
||||
{{ 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 }} · {{ 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();
|
||||
|
||||
Reference in New Issue
Block a user