feat(election): server-side pagination + CEO pre-check family loan UI
- election.service.ts: all 3 list APIs now accept page/size params and return PagedResponse - election-dashboard: server-side paginated elections with statusCounts from backend - nomination-list: server-side pagination; summary chips use backend totals (all pages) - candidate-board: server-side pagination; post filter triggers new API call - nomination-detail: full 8-check CEO pre-check display (director loan, relation accounts, KYC, family KYC, guarantor, AML, family loans) - nomination-apply: guarantor status section added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
71552d6875
commit
9e33392b01
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<span class="total-badge">{{ candidates.length }} candidates</span>
|
||||
<span class="total-badge">{{ totalElements }} candidates</span>
|
||||
<div class="post-filter">
|
||||
<button class="filter-btn" [class.active]="!selectedPostId" (click)="selectedPostId = null; loadCandidates()">
|
||||
All Posts
|
||||
@@ -106,5 +106,46 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
|
||||
<div class="pagination-bar">
|
||||
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
|
||||
<div class="pg-controls">
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
|
||||
<mat-icon>first_page</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@for (p of pageNumbers; track $index) {
|
||||
@if (isNumber(p)) {
|
||||
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="pg-ellipsis">…</span>
|
||||
}
|
||||
}
|
||||
<button
|
||||
class="pg-btn"
|
||||
[disabled]="currentPage === totalPages"
|
||||
(click)="goToPage(currentPage + 1)"
|
||||
title="Next"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" title="Last">
|
||||
<mat-icon>last_page</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pg-size">
|
||||
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
|
||||
@for (n of pageSizeOptions; track n) {
|
||||
<option [value]="n">{{ n }} / page</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #f7f8fc;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
@@ -87,9 +87,9 @@
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: #4a6cf7;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #4a6cf7;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,8 @@
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: #f0f4ff;
|
||||
color: #4a6cf7;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
@@ -172,7 +172,7 @@
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
|
||||
background: linear-gradient(135deg, #2563eb, #1e40af);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -296,7 +296,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4a6cf7;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
@@ -307,3 +307,101 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pg-info {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pg-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #4a5568;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-num--active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-size {
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,19 @@ export class CandidateBoardComponent implements OnInit {
|
||||
electionId!: number;
|
||||
election: any = null;
|
||||
candidates: any[] = [];
|
||||
totalElements = 0;
|
||||
loading = true;
|
||||
selectedPostId: number | null = null;
|
||||
|
||||
pageSize = 12;
|
||||
currentPage = 1;
|
||||
readonly pageSizeOptions = [
|
||||
6,
|
||||
12,
|
||||
24,
|
||||
48
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.electionId = +this.route.snapshot.params['electionId'];
|
||||
this.loadElection();
|
||||
@@ -50,21 +60,31 @@ export class CandidateBoardComponent implements OnInit {
|
||||
}
|
||||
|
||||
loadCandidates(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
private fetchPage(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getCandidates(this.electionId, this.selectedPostId ?? undefined)
|
||||
.getCandidates(this.electionId, this.selectedPostId ?? undefined, this.currentPage - 1, this.pageSize)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.loading = false;
|
||||
return of([]);
|
||||
return of({ content: [], totalElements: 0 });
|
||||
})
|
||||
)
|
||||
.subscribe((data: any[]) => {
|
||||
this.candidates = data;
|
||||
.subscribe((resp: any) => {
|
||||
this.candidates = resp.content ?? [];
|
||||
this.totalElements = resp.totalElements ?? 0;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
isNumber(p: number | string): boolean {
|
||||
return typeof p === 'number';
|
||||
}
|
||||
|
||||
removeCandidate(candidate: any): void {
|
||||
if (!confirm(`Remove ${candidate.candidateName} from the candidate list?`)) return;
|
||||
this.service.removeCandidate(this.electionId, candidate.id, 'Removed by admin').subscribe(() => {
|
||||
@@ -78,8 +98,36 @@ export class CandidateBoardComponent implements OnInit {
|
||||
}
|
||||
|
||||
get filteredCandidates(): any[] {
|
||||
if (!this.selectedPostId) return this.candidates;
|
||||
return this.candidates.filter((c) => c.postId === this.selectedPostId);
|
||||
return this.candidates;
|
||||
}
|
||||
get paginatedCandidates(): any[] {
|
||||
return this.candidates;
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.totalElements / this.pageSize));
|
||||
}
|
||||
get startItem(): number {
|
||||
return this.totalElements === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||
}
|
||||
get endItem(): number {
|
||||
return Math.min(this.currentPage * this.pageSize, this.totalElements);
|
||||
}
|
||||
get pageNumbers(): (number | string)[] {
|
||||
return buildPageNumbers(this.currentPage, this.totalPages);
|
||||
}
|
||||
|
||||
goToPage(p: number | string): void {
|
||||
const page = +p;
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.fetchPage();
|
||||
}
|
||||
}
|
||||
|
||||
onPageSizeChange(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
groupByPost(): { postName: string; candidates: any[] }[] {
|
||||
@@ -96,3 +144,13 @@ export class CandidateBoardComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | string)[] = [1];
|
||||
if (current > 3) pages.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
|
||||
if (current < total - 2) pages.push('...');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -7,20 +7,20 @@
|
||||
*/
|
||||
|
||||
// ── Variables ──────────────────────────────────────────────────────────────
|
||||
$primary: #4a6cf7;
|
||||
$primary-d: #3a5ce7;
|
||||
$primary: #2563eb;
|
||||
$primary-d: #1d4ed8;
|
||||
$success: #38a169;
|
||||
$danger: #e53e3e;
|
||||
$text-dark: #1a202c;
|
||||
$text-mid: #4a5568;
|
||||
$text-soft: #718096;
|
||||
$border: #e2e8f0;
|
||||
$bg-page: #f0f4ff;
|
||||
$bg-page: #eff6ff;
|
||||
$bg-white: #fff;
|
||||
$radius-lg: 16px;
|
||||
$radius-md: 10px;
|
||||
$shadow-sm: 0 2px 8px rgb(74 108 247 / 8%);
|
||||
$shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
$shadow-sm: 0 2px 8px rgb(37 99 235 / 8%);
|
||||
$shadow-md: 0 4px 20px rgb(37 99 235 / 13%);
|
||||
|
||||
// ── Page wrapper ───────────────────────────────────────────────────────────
|
||||
.config-container {
|
||||
@@ -56,9 +56,9 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #ebf0ff;
|
||||
background: #dbeafe;
|
||||
color: $primary;
|
||||
border-color: #c3d0fb;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
@@ -99,7 +99,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
padding: 20px 24px;
|
||||
|
||||
&:hover {
|
||||
background: #f7f9ff;
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.mat-step-icon {
|
||||
@@ -186,7 +186,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
.mdc-notched-outline:hover .mdc-notched-outline__leading,
|
||||
.mdc-notched-outline:hover .mdc-notched-outline__notch,
|
||||
.mdc-notched-outline:hover .mdc-notched-outline__trailing {
|
||||
border-color: #b0bfff !important;
|
||||
border-color: #93c5fd !important;
|
||||
}
|
||||
|
||||
.mdc-text-field--focused .mdc-notched-outline__leading,
|
||||
@@ -243,10 +243,10 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4a6cf7 0%, #6a3de8 100%);
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgb(74 108 247 / 35%);
|
||||
box-shadow: 0 4px 12px rgb(37 99 235 / 35%);
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
@@ -256,8 +256,8 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #3a5ce7 0%, #5a2dd8 100%);
|
||||
box-shadow: 0 6px 16px rgb(74 108 247 / 45%);
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
|
||||
box-shadow: 0 6px 16px rgb(37 99 235 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -327,13 +327,13 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $primary;
|
||||
background: #ebf0ff;
|
||||
border: 1px solid #c3d0fb;
|
||||
background: #dbeafe;
|
||||
border: 1px solid #bfdbfe;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #dce6ff;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
@@ -346,7 +346,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
|
||||
// ── Post card ──────────────────────────────────────────────────────────────
|
||||
.post-card {
|
||||
background: #fafbff;
|
||||
background: #f0f6ff;
|
||||
border: 1.5px solid $border;
|
||||
border-radius: $radius-md;
|
||||
padding: 20px 24px;
|
||||
@@ -354,7 +354,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #c3d0fb;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.post-card-header {
|
||||
@@ -370,7 +370,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: $primary;
|
||||
background: #ebf0ff;
|
||||
background: #dbeafe;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -419,7 +419,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: linear-gradient(180deg, $primary, #6a3de8);
|
||||
background: linear-gradient(180deg, #1e3a8a, $primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 16px;
|
||||
background: linear-gradient(90deg, #f0f4ff 0%, #f7f9fc 100%);
|
||||
background: linear-gradient(90deg, #eff6ff 0%, #f7f9fc 100%);
|
||||
border-bottom: 1px solid $border;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -513,7 +513,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: $primary;
|
||||
background: #ebf0ff;
|
||||
background: #dbeafe;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
} @else {
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ elections.length }}</span>
|
||||
<span class="stat-value">{{ totalElements }}</span>
|
||||
<span class="stat-label">Total Elections</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="elections-grid">
|
||||
@for (election of elections; track election.id) {
|
||||
@for (election of paginatedElections; track election.id) {
|
||||
<div class="election-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
@@ -126,5 +126,46 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
|
||||
<div class="pagination-bar">
|
||||
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
|
||||
<div class="pg-controls">
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
|
||||
<mat-icon>first_page</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@for (p of pageNumbers; track $index) {
|
||||
@if (isNumber(p)) {
|
||||
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="pg-ellipsis">…</span>
|
||||
}
|
||||
}
|
||||
<button
|
||||
class="pg-btn"
|
||||
[disabled]="currentPage === totalPages"
|
||||
(click)="goToPage(currentPage + 1)"
|
||||
title="Next"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" title="Last">
|
||||
<mat-icon>last_page</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pg-size">
|
||||
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
|
||||
@for (n of pageSizeOptions; track n) {
|
||||
<option [value]="n">{{ n }} / page</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@@ -210,21 +210,21 @@
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4a6cf7;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #3a5ce7;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #f7f8fc;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&:hover {
|
||||
background: #edf0ff;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4a6cf7;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
@@ -322,3 +322,101 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.pg-info {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pg-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #4a5568;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-num--active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-size {
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError, of } from 'rxjs';
|
||||
@@ -20,6 +21,7 @@ import { ElectionService } from '../../election.service';
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
@@ -31,8 +33,19 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
elections: any[] = [];
|
||||
totalElements = 0;
|
||||
statusCounts: Record<string, number> = {};
|
||||
loading = true;
|
||||
|
||||
pageSize = 6;
|
||||
currentPage = 1;
|
||||
readonly pageSizeOptions = [
|
||||
3,
|
||||
6,
|
||||
12,
|
||||
24
|
||||
];
|
||||
|
||||
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
|
||||
DRAFT: { label: 'Draft', cssClass: 'status--draft' },
|
||||
ACTIVE: { label: 'Active', cssClass: 'status--active' },
|
||||
@@ -44,21 +57,28 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
private fetchPage(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getAllElections()
|
||||
.getAllElections(this.currentPage - 1, this.pageSize)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.loading = false;
|
||||
return of([]);
|
||||
return of({ content: [], totalElements: 0, statusCounts: {} });
|
||||
})
|
||||
)
|
||||
.subscribe((data: any[]) => {
|
||||
this.elections = data;
|
||||
.subscribe((resp: any) => {
|
||||
this.elections = resp.content ?? [];
|
||||
this.totalElements = resp.totalElements ?? 0;
|
||||
this.statusCounts = resp.statusCounts ?? {};
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
@@ -84,10 +104,42 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
countByStatus(status: string): number {
|
||||
return this.elections.filter((e) => e.status === status).length;
|
||||
return this.statusCounts[status] ?? 0;
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.totalElements / this.pageSize));
|
||||
}
|
||||
get startItem(): number {
|
||||
return this.totalElements === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||
}
|
||||
get endItem(): number {
|
||||
return Math.min(this.currentPage * this.pageSize, this.totalElements);
|
||||
}
|
||||
get paginatedElections(): any[] {
|
||||
return this.elections;
|
||||
}
|
||||
get pageNumbers(): (number | string)[] {
|
||||
return buildPageNumbers(this.currentPage, this.totalPages);
|
||||
}
|
||||
|
||||
goToPage(p: number | string): void {
|
||||
const page = +p;
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.fetchPage();
|
||||
}
|
||||
}
|
||||
|
||||
onPageSizeChange(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
actionLoading: Record<number, boolean> = {};
|
||||
isNumber(p: number | string): boolean {
|
||||
return typeof p === 'number';
|
||||
}
|
||||
|
||||
activate(election: any): void {
|
||||
this.actionLoading[election.id] = true;
|
||||
@@ -105,7 +157,7 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
this.actionLoading[election.id] = false;
|
||||
if (!res) return;
|
||||
this.snackBar.open('Election activated — nominations are now open!', 'Close', { duration: 3000 });
|
||||
this.load();
|
||||
this.fetchPage();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +176,7 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
this.actionLoading[election.id] = false;
|
||||
if (res) {
|
||||
this.snackBar.open('Nominations closed', 'Close', { duration: 3000 });
|
||||
this.load();
|
||||
this.fetchPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -144,8 +196,18 @@ export class ElectionDashboardComponent implements OnInit {
|
||||
this.actionLoading[election.id] = false;
|
||||
if (res) {
|
||||
this.snackBar.open('Voting is now open!', 'Close', { duration: 3000 });
|
||||
this.load();
|
||||
this.fetchPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | string)[] = [1];
|
||||
if (current > 3) pages.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
|
||||
if (current < total - 2) pages.push('...');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4a6cf7;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -823,53 +823,82 @@
|
||||
<!-- Nominee Loan Accounts -->
|
||||
@if (nomineeLoanAccounts.length > 0) {
|
||||
<div class="elig-sub-label"><mat-icon>account_balance</mat-icon> Nominee — Loan Accounts</div>
|
||||
<div class="elig-table-wrap">
|
||||
<table class="elig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Product</th>
|
||||
<th>Principal</th>
|
||||
<th>Outstanding</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (loan of nomineeLoanAccounts; track loan.id) {
|
||||
<tr>
|
||||
<td class="mono">{{ loan.accountNo }}</td>
|
||||
<td>{{ loan.productName }}</td>
|
||||
<td class="amt">
|
||||
₹{{
|
||||
loan.originalLoan ?? loan.summary?.principalDisbursed ?? 0 | number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
<td class="amt">₹{{ loan.summary?.totalOutstanding ?? 0 | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span class="loan-status" [class]="'loan-status--' + loanStatusClass(loan)">{{
|
||||
loanStatusLabel(loan)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@for (loan of nomineeLoanAccounts; track loan.id) {
|
||||
<div class="elig-loan-card" [class.elig-loan-card--overdue]="isLoanOverdue(loan)">
|
||||
<div class="elig-loan-hdr">
|
||||
<span class="mono" style="font-size: 12px; color: #2563eb">{{ loan.accountNo }}</span>
|
||||
<span class="elig-loan-product">{{ loan.productName }}</span>
|
||||
<span class="loan-status loan-status--{{ loanStatusClass(loan) }}">{{
|
||||
loanStatusLabel(loan)
|
||||
}}</span>
|
||||
@if (loan.inArrears) {
|
||||
<span class="loan-status loan-status--overdue">In Arrears</span>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="elig-loan-amounts">
|
||||
<div>
|
||||
<span class="elig-la-lbl">Loan Amount</span
|
||||
><span class="elig-la-val">₹{{ loan.originalLoan ?? 0 | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Outstanding</span
|
||||
><span class="elig-la-val elig-la-val--red"
|
||||
>₹{{ loan.loanBalance ?? 0 | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Paid</span
|
||||
><span class="elig-la-val elig-la-val--green"
|
||||
>₹{{ loan.amountPaid ?? 0 | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@if (loan.timeline) {
|
||||
<div class="elig-loan-tl">
|
||||
<div>
|
||||
<span class="elig-la-lbl">Submitted</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.submittedOnDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Approved</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.approvedOnDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Disbursed</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.actualDisbursementDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Matures</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.expectedMaturityDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="elig-empty-row">
|
||||
<mat-icon>info</mat-icon> No loan accounts found for nominee.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Nominee Savings Accounts -->
|
||||
@if (nomineeSavingsAccounts.length > 0) {
|
||||
<div class="elig-sub-label"><mat-icon>savings</mat-icon> Nominee — Savings Accounts</div>
|
||||
<!-- Nominee Deposit Accounts (savings + current) -->
|
||||
@if (nomineeSavingsAccounts.length + nomineeCurrentAccounts.length > 0) {
|
||||
<div class="elig-sub-label"><mat-icon>savings</mat-icon> Nominee — Deposit Accounts</div>
|
||||
<div class="elig-table-wrap">
|
||||
<table class="elig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Product</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
@@ -879,7 +908,12 @@
|
||||
<tr>
|
||||
<td class="mono">{{ sav.accountNo }}</td>
|
||||
<td>{{ sav.productName }}</td>
|
||||
<td class="amt">₹{{ sav.accountBalance ?? sav.balance ?? 0 | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span class="acc-type-badge acc-type-badge--{{ depositTypeClass(sav) }}">{{
|
||||
depositTypeLabel(sav)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="amt">₹{{ sav.accountBalance ?? sav.balance ?? 0 | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-status"
|
||||
@@ -893,6 +927,29 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (ca of nomineeCurrentAccounts; track ca.id) {
|
||||
<tr>
|
||||
<td class="mono">{{ ca.accountNo }}</td>
|
||||
<td>{{ ca.productName }}</td>
|
||||
<td>
|
||||
<span class="acc-type-badge acc-type-badge--{{ depositTypeClass(ca) }}">{{
|
||||
depositTypeLabel(ca)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="amt">₹{{ ca.accountBalance ?? ca.balance ?? 0 | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-status"
|
||||
[class]="
|
||||
(ca.status?.value ?? '').toLowerCase().includes('active')
|
||||
? 'loan-status--active'
|
||||
: 'loan-status--closed'
|
||||
"
|
||||
>{{ ca.status?.value ?? '—' }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -920,49 +977,76 @@
|
||||
<div class="elig-sub-label elig-sub-label--fm">
|
||||
<mat-icon>account_balance</mat-icon> Loan Accounts
|
||||
</div>
|
||||
<div class="elig-table-wrap">
|
||||
<table class="elig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Product</th>
|
||||
<th>Principal</th>
|
||||
<th>Outstanding</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (loan of fmAcc.loanAccounts; track loan.id) {
|
||||
<tr>
|
||||
<td class="mono">{{ loan.accountNo }}</td>
|
||||
<td>{{ loan.productName }}</td>
|
||||
<td class="amt">
|
||||
₹{{
|
||||
loan.originalLoan ?? loan.summary?.principalDisbursed ?? 0
|
||||
| number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
<td class="amt">
|
||||
₹{{ loan.summary?.totalOutstanding ?? 0 | number: '1.0-0' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-status"
|
||||
[class]="'loan-status--' + loanStatusClass(loan)"
|
||||
>{{ loanStatusLabel(loan) }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
@for (loan of fmAcc.loanAccounts; track loan.id) {
|
||||
<div class="elig-loan-card" [class.elig-loan-card--overdue]="isLoanOverdue(loan)">
|
||||
<div class="elig-loan-hdr">
|
||||
<span class="mono" style="font-size: 12px; color: #2563eb">{{
|
||||
loan.accountNo
|
||||
}}</span>
|
||||
<span class="elig-loan-product">{{ loan.productName }}</span>
|
||||
<span class="loan-status loan-status--{{ loanStatusClass(loan) }}">{{
|
||||
loanStatusLabel(loan)
|
||||
}}</span>
|
||||
@if (loan.inArrears) {
|
||||
<span class="loan-status loan-status--overdue">In Arrears</span>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="elig-loan-amounts">
|
||||
<div>
|
||||
<span class="elig-la-lbl">Loan Amount</span
|
||||
><span class="elig-la-val"
|
||||
>₹{{ loan.originalLoan ?? 0 | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Outstanding</span
|
||||
><span class="elig-la-val elig-la-val--red"
|
||||
>₹{{ loan.loanBalance ?? 0 | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Paid</span
|
||||
><span class="elig-la-val elig-la-val--green"
|
||||
>₹{{ loan.amountPaid ?? 0 | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@if (loan.timeline) {
|
||||
<div class="elig-loan-tl">
|
||||
<div>
|
||||
<span class="elig-la-lbl">Submitted</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.submittedOnDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Approved</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.approvedOnDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Disbursed</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.actualDisbursementDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="elig-la-lbl">Matures</span
|
||||
><span class="elig-la-date">{{
|
||||
parseDob(loan.timeline.expectedMaturityDate) | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="elig-empty-row"><mat-icon>info</mat-icon> No loan accounts.</div>
|
||||
}
|
||||
@if ((fmAcc.savingsAccounts?.length ?? 0) > 0) {
|
||||
@if ((fmAcc.savingsAccounts?.length ?? 0) + (fmAcc.currentAccounts?.length ?? 0) > 0) {
|
||||
<div class="elig-sub-label elig-sub-label--fm">
|
||||
<mat-icon>savings</mat-icon> Savings Accounts
|
||||
<mat-icon>savings</mat-icon> Deposit Accounts
|
||||
</div>
|
||||
<div class="elig-table-wrap">
|
||||
<table class="elig-table">
|
||||
@@ -970,6 +1054,7 @@
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Product</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
@@ -979,8 +1064,13 @@
|
||||
<tr>
|
||||
<td class="mono">{{ sav.accountNo }}</td>
|
||||
<td>{{ sav.productName }}</td>
|
||||
<td>
|
||||
<span class="acc-type-badge acc-type-badge--{{ depositTypeClass(sav) }}">{{
|
||||
depositTypeLabel(sav)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="amt">
|
||||
₹{{ sav.accountBalance ?? sav.balance ?? 0 | number: '1.0-0' }}
|
||||
₹{{ sav.accountBalance ?? sav.balance ?? 0 | number: '1.2-2' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
@@ -995,6 +1085,31 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (ca of fmAcc.currentAccounts; track ca.id) {
|
||||
<tr>
|
||||
<td class="mono">{{ ca.accountNo }}</td>
|
||||
<td>{{ ca.productName }}</td>
|
||||
<td>
|
||||
<span class="acc-type-badge acc-type-badge--{{ depositTypeClass(ca) }}">{{
|
||||
depositTypeLabel(ca)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="amt">
|
||||
₹{{ ca.accountBalance ?? ca.balance ?? 0 | number: '1.2-2' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-status"
|
||||
[class]="
|
||||
(ca.status?.value ?? '').toLowerCase().includes('active')
|
||||
? 'loan-status--active'
|
||||
: 'loan-status--closed'
|
||||
"
|
||||
>{{ ca.status?.value ?? '—' }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1090,13 +1205,17 @@
|
||||
}
|
||||
|
||||
<!-- Loan health indicator -->
|
||||
@if (g.inArrears && g.totalOverdue > 0) {
|
||||
@if ((g.inArrears && g.totalOverdue > 0) || g.missedInstallments > 0) {
|
||||
<div class="guarantor-health-row guarantor-health-row--problem">
|
||||
<mat-icon>warning</mat-icon>
|
||||
Overdue: <strong>{{ g.totalOverdue | number: '1.2-2' }}</strong>
|
||||
@if (g.overdueSinceDate) {
|
||||
· Since {{ g.overdueSinceDate | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
@if (g.missedInstallments > 0) {
|
||||
·
|
||||
<span class="guarantor-missed-badge">{{ g.missedInstallments }} EMI missed</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="guarantor-health-row guarantor-health-row--ok">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -271,6 +271,18 @@ export class NominationApplyComponent implements OnInit {
|
||||
return map[status] ?? 'pending';
|
||||
}
|
||||
|
||||
depositTypeClass(acc: any): string {
|
||||
const code: string = acc?.depositType?.code ?? '';
|
||||
if (code.includes('fixedDeposit')) return 'fd';
|
||||
if (code.includes('recurringDeposit')) return 'rd';
|
||||
if (code.includes('currentDeposit')) return 'current';
|
||||
return 'savings';
|
||||
}
|
||||
|
||||
depositTypeLabel(acc: any): string {
|
||||
return acc?.depositType?.value || 'Savings';
|
||||
}
|
||||
|
||||
riskLabel(risk: number): string {
|
||||
const map: Record<number, string> = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' };
|
||||
return map[risk] ?? '—';
|
||||
@@ -292,13 +304,13 @@ export class NominationApplyComponent implements OnInit {
|
||||
isLoanOverdue(loan: any): boolean {
|
||||
const isClosed = loan.status?.id === 600 || (loan.status?.value ?? '').toString().toLowerCase().includes('closed');
|
||||
if (isClosed) return false;
|
||||
return (loan.summary?.totalOverdue ?? 0) > 0;
|
||||
return (loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true;
|
||||
}
|
||||
|
||||
loanStatusLabel(loan: any): string {
|
||||
const statusVal = (loan.status?.value ?? '').toString().toLowerCase();
|
||||
if (statusVal.includes('closed')) return 'Closed';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0) return 'Overdue';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true) return 'Overdue';
|
||||
if (statusVal.includes('active')) return 'Active';
|
||||
return loan.status?.value ?? '—';
|
||||
}
|
||||
@@ -306,7 +318,7 @@ export class NominationApplyComponent implements OnInit {
|
||||
loanStatusClass(loan: any): string {
|
||||
const statusVal = (loan.status?.value ?? '').toString().toLowerCase();
|
||||
if (statusVal.includes('closed')) return 'closed';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0) return 'overdue';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
@@ -322,6 +334,10 @@ export class NominationApplyComponent implements OnInit {
|
||||
return this.nomineeAccounts?.savingsAccounts ?? [];
|
||||
}
|
||||
|
||||
get nomineeCurrentAccounts(): any[] {
|
||||
return this.nomineeAccounts?.currentAccounts ?? [];
|
||||
}
|
||||
|
||||
get nomineeGuarantorAccounts(): any[] {
|
||||
return this.nomineeAccounts?.guarantorAccounts ?? [];
|
||||
}
|
||||
@@ -564,6 +580,15 @@ export class NominationApplyComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedShareAccountNo) {
|
||||
this.snackBar.open(
|
||||
'Cannot submit: Member does not have an active share account. Only shareholders can apply for nomination.',
|
||||
'Close',
|
||||
{ duration: 8000, panelClass: 'snack-error' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manualMembers = this.manualEntryMembers;
|
||||
if (manualMembers.length > 0) {
|
||||
const names = manualMembers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,18 +7,20 @@
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, NgTemplateOutlet } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { catchError, forkJoin, of } from 'rxjs';
|
||||
import { ElectionService } from '../../election.service';
|
||||
import { ClientsService } from '../../../clients/clients.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-nomination-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgTemplateOutlet,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
@@ -28,6 +30,7 @@ import { ElectionService } from '../../election.service';
|
||||
})
|
||||
export class NominationDetailComponent implements OnInit {
|
||||
private service = inject(ElectionService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@@ -37,17 +40,26 @@ export class NominationDetailComponent implements OnInit {
|
||||
loading = true;
|
||||
actionLoading = false;
|
||||
|
||||
nomineeAccounts: any = null;
|
||||
kycData: any = null;
|
||||
familyMembers: any[] = [];
|
||||
familyMemberAccountsMap: Record<string, any> = {};
|
||||
familyMemberKycMap: Record<string, any> = {};
|
||||
accountsLoading = false;
|
||||
familyKycLoading = false;
|
||||
expandedFamilyCards = new Set<number>();
|
||||
familyExpanded = true;
|
||||
kycExpanded = true;
|
||||
eligExpanded = true;
|
||||
guarantorsExpanded = true;
|
||||
|
||||
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
|
||||
SUBMITTED: { label: 'Submitted', cssClass: 'chip--submitted' },
|
||||
UNDER_REVIEW: { label: 'Under Review', cssClass: 'chip--review' },
|
||||
CEO_CHECK_PENDING: { label: 'CEO Check Pending', cssClass: 'chip--ceo' },
|
||||
CEO_APPROVED: { label: 'CEO Approved', cssClass: 'chip--ceo-ok' },
|
||||
CEO_REJECTED: { label: 'CEO Rejected', cssClass: 'chip--rejected' },
|
||||
APPROVAL_PENDING: { label: 'Approval Pending', cssClass: 'chip--pending' },
|
||||
APPROVED: { label: 'Approved', cssClass: 'chip--approved' },
|
||||
REJECTED: { label: 'Rejected', cssClass: 'chip--rejected' },
|
||||
WITHDRAWN: { label: 'Withdrawn', cssClass: 'chip--withdrawn' },
|
||||
DRAFT: { label: 'Draft', cssClass: 'chip--draft' }
|
||||
WITHDRAWN: { label: 'Withdrawn', cssClass: 'chip--withdrawn' }
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -69,9 +81,182 @@ export class NominationDetailComponent implements OnInit {
|
||||
.subscribe((data: any) => {
|
||||
this.nomination = data;
|
||||
this.loading = false;
|
||||
if (data?.memberId) {
|
||||
this.loadClientData(data.memberId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadClientData(clientId: number): void {
|
||||
this.accountsLoading = true;
|
||||
forkJoin({
|
||||
accounts: this.clientsService.getClientAccountData(clientId.toString()).pipe(catchError(() => of(null))),
|
||||
family: this.clientsService.getClientFamilyMembers(clientId.toString()).pipe(catchError(() => of([]))),
|
||||
kyc: this.clientsService.getClientKyc(clientId.toString()).pipe(catchError(() => of(null)))
|
||||
}).subscribe(({ accounts, family, kyc }: any) => {
|
||||
this.nomineeAccounts = accounts;
|
||||
this.kycData = kyc;
|
||||
this.familyMembers = Array.isArray(family) ? family : [];
|
||||
this.accountsLoading = false;
|
||||
|
||||
const linkedMembers = this.familyMembers.filter((m) => !!m.linkedClientId);
|
||||
if (linkedMembers.length > 0) {
|
||||
this.familyKycLoading = true;
|
||||
const accCalls: Record<string, any> = {};
|
||||
const kycCalls: Record<string, any> = {};
|
||||
linkedMembers.forEach((m) => {
|
||||
accCalls['c' + m.linkedClientId] = this.clientsService
|
||||
.getClientAccountData(m.linkedClientId.toString())
|
||||
.pipe(catchError(() => of(null)));
|
||||
kycCalls['c' + m.linkedClientId] = this.clientsService
|
||||
.getClientKyc(m.linkedClientId.toString())
|
||||
.pipe(catchError(() => of(null)));
|
||||
});
|
||||
forkJoin(accCalls).subscribe((map: any) => {
|
||||
this.familyMemberAccountsMap = map;
|
||||
});
|
||||
forkJoin(kycCalls).subscribe((map: any) => {
|
||||
this.familyMemberKycMap = map;
|
||||
this.familyKycLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get nomineeLoanAccounts(): any[] {
|
||||
return (this.nomineeAccounts?.loanAccounts ?? []).filter((a: any) => a.status?.active === true);
|
||||
}
|
||||
get nomineeSavingsAccounts(): any[] {
|
||||
return (this.nomineeAccounts?.savingsAccounts ?? []).filter((a: any) => a.status?.active === true);
|
||||
}
|
||||
get nomineeCurrentAccounts(): any[] {
|
||||
return (this.nomineeAccounts?.currentAccounts ?? []).filter((a: any) => a.status?.active === true);
|
||||
}
|
||||
get nomineeGuarantorAccounts(): any[] {
|
||||
return (this.nomineeAccounts?.guarantorAccounts ?? []).filter((a: any) => a.status?.active === true);
|
||||
}
|
||||
|
||||
activeOnly(accounts: any[] | null | undefined): any[] {
|
||||
return (accounts ?? []).filter((a: any) => a.status?.active === true);
|
||||
}
|
||||
|
||||
familyMemberAccounts(linkedClientId: number): any {
|
||||
return this.familyMemberAccountsMap['c' + linkedClientId] ?? null;
|
||||
}
|
||||
|
||||
getInitials(name: string | null | undefined): string {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((n: string) => n[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
toggleFamilyCard(id: number): void {
|
||||
if (this.expandedFamilyCards.has(id)) this.expandedFamilyCards.delete(id);
|
||||
else this.expandedFamilyCards.add(id);
|
||||
}
|
||||
|
||||
isLinkedClient(member: any): boolean {
|
||||
return !!member.linkedClientId;
|
||||
}
|
||||
|
||||
familyMemberDisplayName(member: any): string {
|
||||
if (member.linkedClientName) return member.linkedClientName;
|
||||
return (member.firstName ?? '').toString().trim() || '—';
|
||||
}
|
||||
|
||||
parseDob(dob: any): Date | null {
|
||||
if (!dob) return null;
|
||||
if (Array.isArray(dob)) return new Date(dob[0], dob[1] - 1, dob[2]);
|
||||
return new Date(dob);
|
||||
}
|
||||
|
||||
kycStatusLabel(status: number): string {
|
||||
const m: Record<number, string> = {
|
||||
100: 'Pending',
|
||||
200: 'In Progress',
|
||||
300: 'Verified',
|
||||
400: 'Rejected',
|
||||
500: 'Expired'
|
||||
};
|
||||
return m[status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
kycStatusClass(status: number): string {
|
||||
const m: Record<number, string> = {
|
||||
100: 'pending',
|
||||
200: 'inprogress',
|
||||
300: 'verified',
|
||||
400: 'rejected',
|
||||
500: 'expired'
|
||||
};
|
||||
return m[status] ?? 'pending';
|
||||
}
|
||||
|
||||
depositTypeClass(acc: any): string {
|
||||
const code: string = acc?.depositType?.code ?? '';
|
||||
if (code.includes('fixedDeposit')) return 'fd';
|
||||
if (code.includes('recurringDeposit')) return 'rd';
|
||||
if (code.includes('currentDeposit')) return 'current';
|
||||
return 'savings';
|
||||
}
|
||||
|
||||
depositTypeLabel(acc: any): string {
|
||||
return acc?.depositType?.value || 'Savings';
|
||||
}
|
||||
|
||||
riskLabel(risk: number): string {
|
||||
return { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' }[risk] ?? '—';
|
||||
}
|
||||
|
||||
riskRekyc(risk: number): string {
|
||||
return { 1: 'Re-KYC every 10 years', 2: 'Re-KYC every 8 years', 3: 'Re-KYC every 2 years' }[risk] ?? '';
|
||||
}
|
||||
|
||||
riskClass(risk: number): string {
|
||||
return risk === 1 ? 'low' : risk === 2 ? 'medium' : risk === 3 ? 'high' : '';
|
||||
}
|
||||
|
||||
kycExpiryInfo(expiryDate: any): { date: Date | null; isExpired: boolean; isExpiringSoon: boolean; daysLeft: number } {
|
||||
const date = this.parseDob(expiryDate);
|
||||
if (!date) return { date: null, isExpired: false, isExpiringSoon: false, daysLeft: 0 };
|
||||
const daysLeft = Math.floor((date.getTime() - new Date().getTime()) / 86400000);
|
||||
return { date, isExpired: daysLeft < 0, isExpiringSoon: daysLeft >= 0 && daysLeft <= 180, daysLeft };
|
||||
}
|
||||
|
||||
get isEligible(): boolean {
|
||||
if (!this.nomineeAccounts) return true;
|
||||
if ((this.nomineeAccounts?.loanAccounts ?? []).some((l: any) => this.isLoanOverdue(l))) return false;
|
||||
for (const key of Object.keys(this.familyMemberAccountsMap)) {
|
||||
if ((this.familyMemberAccountsMap[key]?.loanAccounts ?? []).some((l: any) => this.isLoanOverdue(l))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
isLoanOverdue(loan: any): boolean {
|
||||
const isClosed = loan.status?.id === 600 || (loan.status?.value ?? '').toString().toLowerCase().includes('closed');
|
||||
if (isClosed) return false;
|
||||
return (loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true;
|
||||
}
|
||||
|
||||
loanStatusLabel(loan: any): string {
|
||||
const s = (loan.status?.value ?? '').toString().toLowerCase();
|
||||
if (s.includes('closed')) return 'Closed';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true) return 'Overdue';
|
||||
if (s.includes('active')) return 'Active';
|
||||
return loan.status?.value ?? '—';
|
||||
}
|
||||
|
||||
loanStatusClass(loan: any): string {
|
||||
const s = (loan.status?.value ?? '').toString().toLowerCase();
|
||||
if (s.includes('closed')) return 'closed';
|
||||
if ((loan.summary?.totalOverdue ?? 0) > 0 || loan.inArrears === true) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
runEligibilityCheck(): void {
|
||||
this.actionLoading = true;
|
||||
this.service
|
||||
@@ -130,6 +315,25 @@ export class NominationDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
resubmit(): void {
|
||||
this.actionLoading = true;
|
||||
this.service
|
||||
.resubmitNomination(this.electionId, this.nominationId)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.snackBar.open('Re-submit failed', 'Close', { duration: 3000 });
|
||||
this.actionLoading = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
if (!res) return;
|
||||
this.actionLoading = false;
|
||||
this.load();
|
||||
this.snackBar.open('Nomination re-submitted for review', 'Close', { duration: 3000 });
|
||||
});
|
||||
}
|
||||
|
||||
approveCandidate(): void {
|
||||
this.service.approveCandidate(this.electionId, this.nominationId).subscribe(() => {
|
||||
this.load();
|
||||
@@ -160,24 +364,30 @@ export class NominationDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
get canRunCeoCheck(): boolean {
|
||||
return [
|
||||
'UNDER_REVIEW',
|
||||
'CEO_CHECK_PENDING'
|
||||
].includes(this.nomination?.status);
|
||||
return this.nomination?.status === 'UNDER_REVIEW';
|
||||
}
|
||||
|
||||
get canGenerateNDC(): boolean {
|
||||
return this.nomination?.status === 'CEO_CHECK_PENDING' && !this.nomination?.ceoPreCheck?.noDueCertificateNumber;
|
||||
return (
|
||||
this.nomination?.status === 'APPROVAL_PENDING' &&
|
||||
!!this.nomination?.ceoPreCheck &&
|
||||
!this.nomination?.ceoPreCheck?.noDueCertificateNumber
|
||||
);
|
||||
}
|
||||
|
||||
get canApproveStep(): boolean {
|
||||
return [
|
||||
'CEO_APPROVED',
|
||||
'APPROVAL_PENDING'
|
||||
].includes(this.nomination?.status);
|
||||
return this.nomination?.status === 'APPROVAL_PENDING';
|
||||
}
|
||||
|
||||
get canResubmit(): boolean {
|
||||
return this.nomination?.status === 'REJECTED';
|
||||
}
|
||||
|
||||
get canApproveCandidate(): boolean {
|
||||
return this.nomination?.status === 'APPROVED';
|
||||
return this.nomination?.status === 'APPROVED' && !this.nomination?.candidateId;
|
||||
}
|
||||
|
||||
get isAlreadyCandidate(): boolean {
|
||||
return this.nomination?.status === 'APPROVED' && !!this.nomination?.candidateId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (nom of nominations; track nom.id) {
|
||||
@for (nom of paginatedNominations; track nom.id) {
|
||||
<tr>
|
||||
<td class="app-no">{{ nom.applicationNumber ?? '–' }}</td>
|
||||
<td>
|
||||
@@ -90,5 +90,46 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
|
||||
<div class="pagination-bar">
|
||||
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
|
||||
<div class="pg-controls">
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
|
||||
<mat-icon>first_page</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@for (p of pageNumbers; track $index) {
|
||||
@if (isNumber(p)) {
|
||||
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
|
||||
{{ p }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="pg-ellipsis">…</span>
|
||||
}
|
||||
}
|
||||
<button
|
||||
class="pg-btn"
|
||||
[disabled]="currentPage === totalPages"
|
||||
(click)="goToPage(currentPage + 1)"
|
||||
title="Next"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" title="Last">
|
||||
<mat-icon>last_page</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pg-size">
|
||||
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
|
||||
@for (n of pageSizeOptions; track n) {
|
||||
<option [value]="n">{{ n }} / page</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #4a6cf7;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
@@ -109,7 +109,7 @@
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
border-color: #4a6cf7;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: #fafbff;
|
||||
background: #f0f6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@
|
||||
.app-no {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
@@ -184,11 +184,6 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&.chip--draft {
|
||||
background: #edf2f7;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
&.chip--submitted {
|
||||
background: #bee3f8;
|
||||
color: #2a69ac;
|
||||
@@ -199,16 +194,6 @@
|
||||
color: #744210;
|
||||
}
|
||||
|
||||
&.chip--ceo {
|
||||
background: #e2d9f3;
|
||||
color: #6b46c1;
|
||||
}
|
||||
|
||||
&.chip--ceo-ok {
|
||||
background: #c6f6d5;
|
||||
color: #276749;
|
||||
}
|
||||
|
||||
&.chip--pending {
|
||||
background: #fed7a0;
|
||||
color: #7b341e;
|
||||
@@ -236,12 +221,12 @@
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #f0f4ff;
|
||||
color: #4a6cf7;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: #e0e7ff;
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +251,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4a6cf7;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
@@ -277,3 +262,101 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 0 0 12px 12px;
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
.pg-info {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pg-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #4a5568;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #eff6ff;
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-num--active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-size {
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,26 @@ export class NominationListComponent implements OnInit {
|
||||
electionId!: number;
|
||||
election: any = null;
|
||||
nominations: any[] = [];
|
||||
totalElements = 0;
|
||||
summaryApproved = 0;
|
||||
summaryPending = 0;
|
||||
summaryRejected = 0;
|
||||
loading = true;
|
||||
filterStatus = '';
|
||||
|
||||
pageSize = 10;
|
||||
currentPage = 1;
|
||||
readonly pageSizeOptions = [
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
50
|
||||
];
|
||||
|
||||
statusOptions = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'SUBMITTED', label: 'Submitted' },
|
||||
{ value: 'UNDER_REVIEW', label: 'Under Review' },
|
||||
{ value: 'CEO_CHECK_PENDING', label: 'CEO Check Pending' },
|
||||
{ value: 'CEO_APPROVED', label: 'CEO Approved' },
|
||||
{ value: 'APPROVAL_PENDING', label: 'Approval Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
@@ -52,12 +63,8 @@ export class NominationListComponent implements OnInit {
|
||||
];
|
||||
|
||||
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
|
||||
DRAFT: { label: 'Draft', cssClass: 'chip--draft' },
|
||||
SUBMITTED: { label: 'Submitted', cssClass: 'chip--submitted' },
|
||||
UNDER_REVIEW: { label: 'Under Review', cssClass: 'chip--review' },
|
||||
CEO_CHECK_PENDING: { label: 'CEO Check', cssClass: 'chip--ceo' },
|
||||
CEO_APPROVED: { label: 'CEO Approved', cssClass: 'chip--ceo-ok' },
|
||||
CEO_REJECTED: { label: 'CEO Rejected', cssClass: 'chip--rejected' },
|
||||
APPROVAL_PENDING: { label: 'Approval Pending', cssClass: 'chip--pending' },
|
||||
APPROVED: { label: 'Approved', cssClass: 'chip--approved' },
|
||||
REJECTED: { label: 'Rejected', cssClass: 'chip--rejected' },
|
||||
@@ -75,21 +82,34 @@ export class NominationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
private fetchPage(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getNominations(this.electionId, this.filterStatus || undefined)
|
||||
.getNominations(this.electionId, this.filterStatus || undefined, this.currentPage - 1, this.pageSize)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.loading = false;
|
||||
return of([]);
|
||||
return of({ content: [], totalElements: 0, summaryApproved: 0, summaryPending: 0, summaryRejected: 0 });
|
||||
})
|
||||
)
|
||||
.subscribe((data: any[]) => {
|
||||
this.nominations = data;
|
||||
.subscribe((resp: any) => {
|
||||
this.nominations = resp.content ?? [];
|
||||
this.totalElements = resp.totalElements ?? 0;
|
||||
this.summaryApproved = resp.summaryApproved ?? 0;
|
||||
this.summaryPending = resp.summaryPending ?? 0;
|
||||
this.summaryRejected = resp.summaryRejected ?? 0;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
isNumber(p: number | string): boolean {
|
||||
return typeof p === 'number';
|
||||
}
|
||||
|
||||
statusLabel(s: string): string {
|
||||
return this.statusConfig[s]?.label ?? s;
|
||||
}
|
||||
@@ -97,21 +117,51 @@ export class NominationListComponent implements OnInit {
|
||||
return this.statusConfig[s]?.cssClass ?? '';
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.max(1, Math.ceil(this.totalElements / this.pageSize));
|
||||
}
|
||||
get startItem(): number {
|
||||
return this.totalElements === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||
}
|
||||
get endItem(): number {
|
||||
return Math.min(this.currentPage * this.pageSize, this.totalElements);
|
||||
}
|
||||
get paginatedNominations(): any[] {
|
||||
return this.nominations;
|
||||
}
|
||||
get pageNumbers(): (number | string)[] {
|
||||
return buildPageNumbers(this.currentPage, this.totalPages);
|
||||
}
|
||||
|
||||
goToPage(p: number | string): void {
|
||||
const page = +p;
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.fetchPage();
|
||||
}
|
||||
}
|
||||
|
||||
onPageSizeChange(): void {
|
||||
this.currentPage = 1;
|
||||
this.fetchPage();
|
||||
}
|
||||
|
||||
get summary() {
|
||||
return {
|
||||
total: this.nominations.length,
|
||||
approved: this.nominations.filter((n) => n.status === 'APPROVED').length,
|
||||
pending: this.nominations.filter((n) => [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'CEO_CHECK_PENDING',
|
||||
'CEO_APPROVED',
|
||||
'APPROVAL_PENDING'
|
||||
].includes(n.status)).length,
|
||||
rejected: this.nominations.filter((n) => [
|
||||
'REJECTED',
|
||||
'CEO_REJECTED'
|
||||
].includes(n.status)).length
|
||||
total: this.totalElements,
|
||||
approved: this.summaryApproved,
|
||||
pending: this.summaryPending,
|
||||
rejected: this.summaryRejected
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildPageNumbers(current: number, total: number): (number | string)[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | string)[] = [1];
|
||||
if (current > 3) pages.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
|
||||
if (current < total - 2) pages.push('...');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
.header-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
|
||||
background: linear-gradient(135deg, #2563eb, #1e40af);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -173,14 +173,14 @@
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 2px 8px rgb(74 108 247 / 15%);
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 15%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #4a6cf7;
|
||||
background: #f0f4ff;
|
||||
box-shadow: 0 0 0 3px rgb(74 108 247 / 20%);
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 20%);
|
||||
}
|
||||
|
||||
&.voted-out {
|
||||
@@ -198,7 +198,7 @@
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
|
||||
background: linear-gradient(135deg, #2563eb, #1e40af);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -253,7 +253,7 @@
|
||||
.voted-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgb(74 108 247 / 10%);
|
||||
background: rgb(37 99 235 / 10%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -263,7 +263,7 @@
|
||||
font-size: 40px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
color: #4a6cf7;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #4a6cf7;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
padding: 12px 28px;
|
||||
border-radius: 10px;
|
||||
@@ -291,7 +291,7 @@
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #3a5ce7;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -321,7 +321,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4a6cf7;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -15,8 +15,9 @@ export class ElectionService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ── Election Config ────────────────────────────────────────────
|
||||
getAllElections(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/election/config');
|
||||
getAllElections(page = 0, size = 6): Observable<any> {
|
||||
const params = new HttpParams().set('page', page).set('size', size);
|
||||
return this.http.get<any>('/election/config', { params });
|
||||
}
|
||||
|
||||
getElection(id: number): Observable<any> {
|
||||
@@ -57,10 +58,10 @@ export class ElectionService {
|
||||
return this.http.post(`/election/${electionId}/nomination`, data);
|
||||
}
|
||||
|
||||
getNominations(electionId: number, status?: string): Observable<any[]> {
|
||||
let params = new HttpParams();
|
||||
getNominations(electionId: number, status?: string, page = 0, size = 10): Observable<any> {
|
||||
let params = new HttpParams().set('page', page).set('size', size);
|
||||
if (status) params = params.set('status', status);
|
||||
return this.http.get<any[]>(`/election/${electionId}/nominations`, { params });
|
||||
return this.http.get<any>(`/election/${electionId}/nominations`, { params });
|
||||
}
|
||||
|
||||
getNomination(electionId: number, nominationId: number): Observable<any> {
|
||||
@@ -71,6 +72,10 @@ export class ElectionService {
|
||||
return this.http.post(`/election/${electionId}/nomination/${nominationId}/withdraw`, {});
|
||||
}
|
||||
|
||||
resubmitNomination(electionId: number, nominationId: number): Observable<any> {
|
||||
return this.http.post(`/election/${electionId}/nomination/${nominationId}/resubmit`, {});
|
||||
}
|
||||
|
||||
runEligibilityCheck(electionId: number, nominationId: number): Observable<any> {
|
||||
return this.http.post(`/election/${electionId}/nomination/${nominationId}/eligibility-check`, {});
|
||||
}
|
||||
@@ -92,10 +97,10 @@ export class ElectionService {
|
||||
}
|
||||
|
||||
// ── Candidates ─────────────────────────────────────────────────
|
||||
getCandidates(electionId: number, postId?: number): Observable<any[]> {
|
||||
let params = new HttpParams();
|
||||
getCandidates(electionId: number, postId?: number, page = 0, size = 12): Observable<any> {
|
||||
let params = new HttpParams().set('page', page).set('size', size);
|
||||
if (postId) params = params.set('postId', postId.toString());
|
||||
return this.http.get<any[]>(`/election/${electionId}/candidates`, { params });
|
||||
return this.http.get<any>(`/election/${electionId}/candidates`, { params });
|
||||
}
|
||||
|
||||
approveCandidate(electionId: number, nominationId: number): Observable<any> {
|
||||
|
||||
Reference in New Issue
Block a user