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 {
|
} @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>
|
→
|
||||||
<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 }} · {{ 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user