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:
Jaanu423
2026-06-26 12:07:38 +05:30
co-authored by Claude Sonnet 4.6
parent 71552d6875
commit 9e33392b01
19 changed files with 4586 additions and 1119 deletions
@@ -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) {
&nbsp;· Since {{ g.overdueSinceDate | date: 'dd MMM yyyy' }}
}
@if (g.missedInstallments > 0) {
&nbsp;·
<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;
+13 -8
View File
@@ -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> {