feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming / On Track), computed client-side from maturityDate so it can never drift out of step with the account. Reads the new lean maturity-dashboard backend endpoint. A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to the account's own Payment dialog -- see the FD account-view commit for that side of it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,17 @@ export class AccountsService {
|
|||||||
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
|
||||||
|
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
|
||||||
|
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
|
||||||
|
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
|
||||||
|
* that had no index until this feature added one (see backend migration 0571).
|
||||||
|
*/
|
||||||
|
getFixedDepositMaturityDashboard(): Observable<any> {
|
||||||
|
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
||||||
let httpParams = new HttpParams()
|
let httpParams = new HttpParams()
|
||||||
.set('paged', 'true')
|
.set('paged', 'true')
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
|||||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||||
import { DeafModule } from './deaf/deaf.module';
|
import { DeafModule } from './deaf/deaf.module';
|
||||||
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||||
|
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
|
||||||
import { DicgcModule } from './dicgc/dicgc.module';
|
import { DicgcModule } from './dicgc/dicgc.module';
|
||||||
import { FiuModule } from './fiu/fiu.module';
|
import { FiuModule } from './fiu/fiu.module';
|
||||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||||
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
BankCalendarModule,
|
BankCalendarModule,
|
||||||
DeafModule,
|
DeafModule,
|
||||||
SavingsDailyInterestModule,
|
SavingsDailyInterestModule,
|
||||||
|
FdDashboardModule,
|
||||||
DicgcModule,
|
DicgcModule,
|
||||||
FiuModule,
|
FiuModule,
|
||||||
GlReportsModule,
|
GlReportsModule,
|
||||||
|
|||||||
@@ -65,6 +65,14 @@
|
|||||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
|
<!-- FD Dashboard fullscreen — ESC hint bar -->
|
||||||
|
@else if (currentUrl.includes('/fd-dashboard')) {
|
||||||
|
<nav class="cashier-nav cashier-nav--election">
|
||||||
|
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
|
||||||
|
<kbd>ESC</kbd> Exit Fullscreen (Show Header & Footer)
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
||||||
@else if (currentUrl.includes('/clerk/')) {
|
@else if (currentUrl.includes('/clerk/')) {
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
@@ -1203,8 +1211,8 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
|
||||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
|
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
url.includes('/clerk/withdrawal-request') ||
|
url.includes('/clerk/withdrawal-request') ||
|
||||||
url.includes('/clerk/my-disbursement-requests');
|
url.includes('/clerk/my-disbursement-requests');
|
||||||
const isElectionRoute = url.includes('/election');
|
const isElectionRoute = url.includes('/election');
|
||||||
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
|
const isFdDashboardRoute = url.includes('/fd-dashboard');
|
||||||
|
this.hideShellLayout =
|
||||||
|
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
};
|
};
|
||||||
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
||||||
@@ -180,10 +182,7 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Toggles the current collapsed state of sidenav according to the emitted event. */
|
||||||
* Toggles the current collapsed state of sidenav according to the emitted event.
|
|
||||||
* @param {boolean} event denotes state of sidenav
|
|
||||||
*/
|
|
||||||
toggleCollapse($event: boolean) {
|
toggleCollapse($event: boolean) {
|
||||||
this.sidenavCollapsed = $event;
|
this.sidenavCollapsed = $event;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -204,12 +203,15 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
if (
|
if (
|
||||||
this.hideShellLayout &&
|
this.hideShellLayout &&
|
||||||
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
|
(this.router.url.includes('/clerk/') ||
|
||||||
|
this.router.url.includes('/election') ||
|
||||||
|
this.router.url.includes('/fd-dashboard') ||
|
||||||
|
this.isWithdrawalReviewUrl)
|
||||||
) {
|
) {
|
||||||
this.hideShellLayout = false;
|
this.hideShellLayout = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
|
|||||||
@@ -154,6 +154,26 @@ export class FixedDepositsService {
|
|||||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
||||||
|
* configured: backdated to the original maturity date within the grace window, or from today
|
||||||
|
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
|
||||||
|
* @param accountId Fixed Deposit Account Id
|
||||||
|
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
|
||||||
|
*/
|
||||||
|
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
|
||||||
|
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
|
||||||
|
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
|
||||||
|
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
|
||||||
|
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
|
||||||
|
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
|
||||||
|
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
|
||||||
|
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param fixedDepositAccount Fixed Deposit Account
|
* @param fixedDepositAccount Fixed Deposit Account
|
||||||
* @returns {Observable<any>}
|
* @returns {Observable<any>}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { Route } from '../core/route/route.service';
|
||||||
|
import { FdDashboardComponent } from './fd-dashboard.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
Route.withShell([
|
||||||
|
{
|
||||||
|
path: 'fd-dashboard',
|
||||||
|
component: FdDashboardComponent,
|
||||||
|
data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class FdDashboardRoutingModule {}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="fdd-container">
|
||||||
|
<!-- Compact Light Header -->
|
||||||
|
<div class="fdd-header">
|
||||||
|
<div class="fdd-header__left">
|
||||||
|
<div class="fdd-header__icon">
|
||||||
|
<fa-icon icon="piggy-bank"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fdd-header__title-row">
|
||||||
|
<h2 class="fdd-header__title">FD Dashboard & Maturity Ladder</h2>
|
||||||
|
<span class="fdd-header__badge">Core Banking</span>
|
||||||
|
</div>
|
||||||
|
<p class="fdd-header__sub">
|
||||||
|
Active Fixed Deposits positioned against maturity dates. Press <kbd>ESC</kbd> to toggle header & footer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fdd-header__right">
|
||||||
|
<button class="fdd-btn fdd-btn--slim" (click)="load()" [disabled]="loading" title="Refresh maturity ladder data">
|
||||||
|
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
|
||||||
|
<span>{{ loading ? 'Syncing...' : 'Reload' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
@if (errorMessage) {
|
||||||
|
<div class="fdd-alert fdd-alert--danger">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Compact Stat Cards Grid -->
|
||||||
|
<div class="fdd-stats-grid">
|
||||||
|
@for (key of stageKeys; track key) {
|
||||||
|
<button
|
||||||
|
[class]="'fdd-stat-card fdd-stat-card--' + stageMeta[key].tone"
|
||||||
|
[class.fdd-stat-card--active]="stageFilter === key"
|
||||||
|
(click)="toggleStage(key)"
|
||||||
|
>
|
||||||
|
<div class="fdd-stat-card__header">
|
||||||
|
<span class="fdd-stat-card__label">{{ stageMeta[key].label }}</span>
|
||||||
|
<div class="fdd-stat-card__icon">
|
||||||
|
@switch (key) {
|
||||||
|
@case ('MATURED') {
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
}
|
||||||
|
@case ('DUE_SOON') {
|
||||||
|
<fa-icon icon="clock"></fa-icon>
|
||||||
|
}
|
||||||
|
@case ('UPCOMING') {
|
||||||
|
<fa-icon icon="calendar-alt"></fa-icon>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fdd-stat-card__val">
|
||||||
|
@switch (key) {
|
||||||
|
@case ('MATURED') {
|
||||||
|
{{ summary.matured }}
|
||||||
|
}
|
||||||
|
@case ('DUE_SOON') {
|
||||||
|
{{ summary.dueSoon }}
|
||||||
|
}
|
||||||
|
@case ('UPCOMING') {
|
||||||
|
{{ summary.upcoming }}
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
{{ summary.onTrack }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="fdd-stat-card__sub">{{ stageMeta[key].summary }}</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact Filter & Search Toolbar -->
|
||||||
|
<div class="fdd-toolbar">
|
||||||
|
<div class="fdd-search-box">
|
||||||
|
<fa-icon icon="search" class="fdd-search-box__icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
class="fdd-search-box__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search account no. or client name..."
|
||||||
|
[(ngModel)]="search"
|
||||||
|
/>
|
||||||
|
@if (search) {
|
||||||
|
<button class="fdd-search-box__clear" (click)="search = ''" title="Clear search">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (stageFilter || search) {
|
||||||
|
<button class="fdd-btn fdd-btn--reset fdd-btn--slim" (click)="resetFilters()">
|
||||||
|
<fa-icon icon="undo"></fa-icon> Reset
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="fdd-toolbar__spacer"></div>
|
||||||
|
|
||||||
|
<div class="fdd-count-chip">
|
||||||
|
<fa-icon icon="layer-group"></fa-icon>
|
||||||
|
<span
|
||||||
|
><strong>{{ visibleRows.length }}</strong> of {{ summary.total }} active FDs</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Worklist Table / Empty State -->
|
||||||
|
@if (loading) {
|
||||||
|
<div class="fdd-card fdd-state-card">
|
||||||
|
<fa-icon icon="spinner" [class.fa-spin]="true" class="fdd-spinner"></fa-icon>
|
||||||
|
<p>Loading FD maturity list...</p>
|
||||||
|
</div>
|
||||||
|
} @else if (visibleRows.length === 0) {
|
||||||
|
<div class="fdd-card fdd-state-card">
|
||||||
|
<fa-icon icon="inbox" class="fdd-state-icon"></fa-icon>
|
||||||
|
<p class="fdd-state-msg">
|
||||||
|
@if (!stageFilter && summary.total > 0) {
|
||||||
|
No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
|
||||||
|
} @else {
|
||||||
|
No FDs match these filters.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (stageFilter || search) {
|
||||||
|
<button class="fdd-btn fdd-btn--primary fdd-btn--slim" (click)="resetFilters()">
|
||||||
|
<fa-icon icon="undo"></fa-icon> Clear Filters
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="fdd-table-card">
|
||||||
|
<div class="fdd-table-wrapper">
|
||||||
|
<table class="fdd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Maturity Date</th>
|
||||||
|
<th class="text-right">Deposit Amount</th>
|
||||||
|
<th class="text-right">Maturity Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-center">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of visibleRows; track r.id) {
|
||||||
|
<tr class="fdd-row" (click)="openAccount(r)">
|
||||||
|
<td>
|
||||||
|
<span class="fdd-acc-num">{{ r.accountNo || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="fdd-client-cell">
|
||||||
|
<span class="fdd-client-name">{{ r.clientName }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="fdd-product-tag">{{ r.productName }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="fdd-date-cell">
|
||||||
|
<span class="fdd-date-val">{{ r.maturityDate }}</span>
|
||||||
|
<span class="fdd-date-sub" [class.fdd-date-sub--overdue]="r.daysOverdue > 0">
|
||||||
|
{{ maturityText(r) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<span class="fdd-amount fdd-amount--deposit">
|
||||||
|
{{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="text-right">
|
||||||
|
<span class="fdd-amount fdd-amount--maturity">
|
||||||
|
{{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="fdd-status-cell">
|
||||||
|
<span [class]="stageClass(r.stage)">
|
||||||
|
{{ r.meta.label }}
|
||||||
|
</span>
|
||||||
|
@if (r.renewable) {
|
||||||
|
<div
|
||||||
|
class="fdd-grace-sub"
|
||||||
|
[class.fdd-grace-sub--warning]="r.renewalGracePeriodDays > 0 && !r.withinGraceWindow"
|
||||||
|
>
|
||||||
|
{{ graceStatusText(r) }}
|
||||||
|
</div>
|
||||||
|
} @else if (r.configuredInstructionLabel) {
|
||||||
|
<div class="fdd-grace-sub">Configured: {{ r.configuredInstructionLabel }} (auto-processes)</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="fdd-act-cell text-center" (click)="$event.stopPropagation()">
|
||||||
|
@if (r.renewable) {
|
||||||
|
<button class="fdd-btn fdd-btn--action" [disabled]="renewing === r.id" (click)="openRenewDialog(r)">
|
||||||
|
@if (renewing === r.id) {
|
||||||
|
<fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Renewing...
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="sync-alt"></fa-icon> Renew
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="fdd-btn-icon" (click)="openAccount(r)" title="View Account Details">
|
||||||
|
<fa-icon icon="chevron-right"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ultra-Compact Light FD Dashboard & Maturity Ladder Styles
|
||||||
|
* CBSNexora Core Banking Design System
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
font-family:
|
||||||
|
'DM Sans',
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light Compact Header ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-header {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Alerts ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-stat-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__val {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tone styling */
|
||||||
|
&--danger {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
|
||||||
|
.fdd-stat-card__icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fdd-stat-card--active {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: #f59e0b;
|
||||||
|
|
||||||
|
.fdd-stat-card__icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fdd-stat-card--active {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
|
||||||
|
.fdd-stat-card__icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fdd-stat-card--active {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
border-left-color: #64748b;
|
||||||
|
|
||||||
|
.fdd-stat-card__icon {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fdd-stat-card--active {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-toolbar {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
|
||||||
|
|
||||||
|
&__spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-search-box {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 300px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 30px 5px 28px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-count-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--slim {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--action {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-btn-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Compact Table ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.fdd-table-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-acc-num {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-client-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-product-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-date-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
|
||||||
|
.fdd-date-val {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-date-sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&--overdue {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-amount {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--deposit {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--maturity {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-status-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-stage {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-grace-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-card.fdd-state-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
.fdd-state-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-spinner {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #2563eb;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdd-state-msg {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
/** Custom Modules */
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { AccountsService } from '../accounts/accounts.service';
|
||||||
|
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
|
||||||
|
import { toIsoDate } from 'app/core/utils/api-date';
|
||||||
|
|
||||||
|
/** Dashboard data + computation */
|
||||||
|
import {
|
||||||
|
FD_MATURITY_POLICY,
|
||||||
|
FD_MATURITY_STAGES,
|
||||||
|
FdMaturityRow,
|
||||||
|
FdMaturityStage,
|
||||||
|
FdMaturitySummary,
|
||||||
|
buildMaturityRow,
|
||||||
|
maturitySummary,
|
||||||
|
sortMaturityRows
|
||||||
|
} from './fd-dashboard.compute';
|
||||||
|
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
|
||||||
|
*
|
||||||
|
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
|
||||||
|
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
|
||||||
|
* screen can never drift out of step with the account -- it always says what today's date implies.
|
||||||
|
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
|
||||||
|
* nothing new was added on the backend for this.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-fd-dashboard',
|
||||||
|
templateUrl: './fd-dashboard.component.html',
|
||||||
|
styleUrls: ['./fd-dashboard.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FdDashboardComponent implements OnInit {
|
||||||
|
private accountsService = inject(AccountsService);
|
||||||
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
|
private router = inject(Router);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
rows: FdMaturityRow[] = [];
|
||||||
|
|
||||||
|
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
|
||||||
|
* against opening a second dialog for the same row while one is already up. */
|
||||||
|
renewing: number | null = null;
|
||||||
|
|
||||||
|
/** Filters */
|
||||||
|
stageFilter: FdMaturityStage | '' = '';
|
||||||
|
search = '';
|
||||||
|
|
||||||
|
readonly policy = FD_MATURITY_POLICY;
|
||||||
|
readonly stageMeta = FD_MATURITY_STAGES;
|
||||||
|
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
|
||||||
|
* attention (matured or maturing soon), matching why this screen exists. */
|
||||||
|
readonly stageKeys: FdMaturityStage[] = [
|
||||||
|
'MATURED',
|
||||||
|
'DUE_SOON',
|
||||||
|
'UPCOMING',
|
||||||
|
'ON_TRACK'
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every active FD and re-derives the maturity ladder for each.
|
||||||
|
*
|
||||||
|
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
|
||||||
|
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
|
||||||
|
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
|
||||||
|
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
|
||||||
|
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
|
||||||
|
* nothing to re-check client-side here.
|
||||||
|
*/
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
const today = toIsoDate(new Date().toISOString());
|
||||||
|
|
||||||
|
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const items: any[] = Array.isArray(res) ? res : [];
|
||||||
|
|
||||||
|
const mapped = items
|
||||||
|
.map((a: any) => this.mapAccount(a))
|
||||||
|
.map((a) => buildMaturityRow(a, today))
|
||||||
|
.filter((r): r is FdMaturityRow => !!r);
|
||||||
|
|
||||||
|
this.rows = sortMaturityRows(mapped);
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.rows = [];
|
||||||
|
this.loading = false;
|
||||||
|
this.errorMessage =
|
||||||
|
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
|
||||||
|
private mapAccount(a: any): {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientId?: number;
|
||||||
|
clientName?: string;
|
||||||
|
productName?: string;
|
||||||
|
savingsProductName?: string;
|
||||||
|
fdType?: string;
|
||||||
|
maturityDate: string;
|
||||||
|
depositAmount?: number;
|
||||||
|
maturityAmount?: number;
|
||||||
|
onClosureType?: number;
|
||||||
|
linkedAccountId?: number;
|
||||||
|
renewalGracePeriodDays?: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: a?.id,
|
||||||
|
accountNo: a?.accountNo || '',
|
||||||
|
clientId: a?.clientId,
|
||||||
|
clientName: a?.clientName,
|
||||||
|
productName: a?.productName,
|
||||||
|
savingsProductName: a?.savingsProductName,
|
||||||
|
fdType: a?.fdType,
|
||||||
|
maturityDate: toIsoDate(a?.maturityDate),
|
||||||
|
depositAmount: a?.depositAmount,
|
||||||
|
maturityAmount: a?.maturityAmount,
|
||||||
|
onClosureType: a?.onClosureType,
|
||||||
|
linkedAccountId: a?.linkedAccountId,
|
||||||
|
renewalGracePeriodDays: a?.renewalGracePeriodDays
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
get visibleRows(): FdMaturityRow[] {
|
||||||
|
const needle = this.search.trim().toLowerCase();
|
||||||
|
const stage = this.stageFilter;
|
||||||
|
return this.rows.filter((r) => {
|
||||||
|
if (stage) {
|
||||||
|
if (r.stage !== stage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (r.stage === 'ON_TRACK') {
|
||||||
|
// No tile selected -- the default view is the worklist, not every active FD.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (needle) {
|
||||||
|
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
|
||||||
|
if (!hay.includes(needle)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get summary(): FdMaturitySummary {
|
||||||
|
return maturitySummary(this.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters(): void {
|
||||||
|
this.stageFilter = '';
|
||||||
|
this.search = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleStage(stage: FdMaturityStage): void {
|
||||||
|
this.stageFilter = this.stageFilter === stage ? '' : stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
openAccount(row: FdMaturityRow): void {
|
||||||
|
if (!row.clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate([
|
||||||
|
'/clients',
|
||||||
|
row.clientId,
|
||||||
|
'fixed-deposits-accounts',
|
||||||
|
row.id,
|
||||||
|
'general'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Renewal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
|
||||||
|
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
|
||||||
|
* and gap-interest crediting decided from the grace window rather than picked here (see
|
||||||
|
* PaymentDialogComponent's `dashboardRenewal` mode). */
|
||||||
|
openRenewDialog(row: FdMaturityRow): void {
|
||||||
|
if (!row.renewable || this.renewing != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renewing = row.id;
|
||||||
|
this.dialog
|
||||||
|
.open(PaymentDialogComponent, {
|
||||||
|
width: '720px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
data: {
|
||||||
|
accountId: row.id,
|
||||||
|
accountBalance: row.maturityAmount,
|
||||||
|
maturityDate: row.maturityDate,
|
||||||
|
dashboardRenewal: { row }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe((result: { confirm: boolean } | undefined) => {
|
||||||
|
this.renewing = null;
|
||||||
|
if (result?.confirm) {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Presentation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
stageClass(stage: FdMaturityStage): string {
|
||||||
|
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
|
||||||
|
maturityText(row: FdMaturityRow): string {
|
||||||
|
if (row.daysOverdue > 0) {
|
||||||
|
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
|
||||||
|
}
|
||||||
|
if (row.daysRemaining === 0) {
|
||||||
|
return 'Matures today';
|
||||||
|
}
|
||||||
|
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
|
||||||
|
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
|
||||||
|
graceStatusText(row: FdMaturityRow): string {
|
||||||
|
if (!row.renewalGracePeriodDays) {
|
||||||
|
return 'Renewable';
|
||||||
|
}
|
||||||
|
const grace = row.renewalGracePeriodDays;
|
||||||
|
if (row.withinGraceWindow) {
|
||||||
|
const daysLeft = grace - row.daysOverdue;
|
||||||
|
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
|
||||||
|
}
|
||||||
|
const daysPastGrace = row.daysOverdue - grace;
|
||||||
|
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an active Fixed Deposit sits relative to its own maturity date.
|
||||||
|
*
|
||||||
|
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
|
||||||
|
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
|
||||||
|
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
|
||||||
|
* before it lands, not just after.
|
||||||
|
*/
|
||||||
|
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
|
||||||
|
|
||||||
|
/** Day-count thresholds, measured forward from today toward the maturity date. */
|
||||||
|
export const FD_MATURITY_POLICY = {
|
||||||
|
/** Maturing within this many days counts as DUE_SOON. */
|
||||||
|
dueSoonDays: 7,
|
||||||
|
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
|
||||||
|
upcomingDays: 30
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FdMaturityStageMeta {
|
||||||
|
stage: FdMaturityStage;
|
||||||
|
label: string;
|
||||||
|
/** Colour key the template maps to a badge/tile class. */
|
||||||
|
tone: 'info' | 'warn' | 'danger' | 'neutral';
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
|
||||||
|
MATURED: {
|
||||||
|
stage: 'MATURED',
|
||||||
|
label: 'Matured',
|
||||||
|
tone: 'danger',
|
||||||
|
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
|
||||||
|
},
|
||||||
|
DUE_SOON: {
|
||||||
|
stage: 'DUE_SOON',
|
||||||
|
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
|
||||||
|
tone: 'warn',
|
||||||
|
summary: 'Matures within the week.'
|
||||||
|
},
|
||||||
|
UPCOMING: {
|
||||||
|
stage: 'UPCOMING',
|
||||||
|
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
|
||||||
|
tone: 'info',
|
||||||
|
summary: 'Matures within the month.'
|
||||||
|
},
|
||||||
|
ON_TRACK: {
|
||||||
|
stage: 'ON_TRACK',
|
||||||
|
label: 'On Track',
|
||||||
|
tone: 'neutral',
|
||||||
|
summary: 'Maturity date is further out.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
|
||||||
|
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
|
||||||
|
const RENEWABLE_FD_TYPES = [
|
||||||
|
'SINGLE',
|
||||||
|
'DOUBLE'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
|
||||||
|
* matured row that already carries an explicit instruction from account-opening. */
|
||||||
|
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
|
||||||
|
200: 'Transfer to Savings',
|
||||||
|
300: 'Reinvest Principal + Interest',
|
||||||
|
400: 'Reinvest Principal Only'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One active Fixed Deposit account, positioned on the maturity ladder. */
|
||||||
|
export interface FdMaturityRow {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
productName: string;
|
||||||
|
maturityDate: string;
|
||||||
|
depositAmount: number | null;
|
||||||
|
maturityAmount: number | null;
|
||||||
|
/** Negative once the maturity date has passed. */
|
||||||
|
daysRemaining: number;
|
||||||
|
/** 0 while not yet matured; how many days past maturity otherwise. */
|
||||||
|
daysOverdue: number;
|
||||||
|
stage: FdMaturityStage;
|
||||||
|
meta: FdMaturityStageMeta;
|
||||||
|
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
|
||||||
|
renewalGracePeriodDays: number | null;
|
||||||
|
linkedAccountId: number | null;
|
||||||
|
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
|
||||||
|
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
|
||||||
|
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
|
||||||
|
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
|
||||||
|
* what this FD should do, grace only changed when that gets carried out. */
|
||||||
|
onClosureType: number | null;
|
||||||
|
/** True only when the account was opened with NO maturity instruction at all (the officer left
|
||||||
|
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
|
||||||
|
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
|
||||||
|
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
|
||||||
|
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
|
||||||
|
* doesn't also offer to renew them. */
|
||||||
|
isReinvestEligible: boolean;
|
||||||
|
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
|
||||||
|
* manual action from the Dashboard is meaningful. */
|
||||||
|
renewable: boolean;
|
||||||
|
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
|
||||||
|
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
|
||||||
|
withinGraceWindow: boolean;
|
||||||
|
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
|
||||||
|
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
|
||||||
|
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
|
||||||
|
* that isn't matured. */
|
||||||
|
configuredInstructionLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days between two ISO dates, positive when `later` is after `earlier`. */
|
||||||
|
function daysBetween(earlier: string, later: string): number {
|
||||||
|
const a = new Date(`${earlier}T00:00:00Z`).getTime();
|
||||||
|
const b = new Date(`${later}T00:00:00Z`).getTime();
|
||||||
|
return Math.round((b - a) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
|
||||||
|
export function maturityStage(daysRemaining: number): FdMaturityStage {
|
||||||
|
if (daysRemaining <= 0) {
|
||||||
|
return 'MATURED';
|
||||||
|
}
|
||||||
|
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
|
||||||
|
return 'DUE_SOON';
|
||||||
|
}
|
||||||
|
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
|
||||||
|
return 'UPCOMING';
|
||||||
|
}
|
||||||
|
return 'ON_TRACK';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
|
||||||
|
export function buildMaturityRow(
|
||||||
|
fd: {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientId?: number;
|
||||||
|
clientName?: string;
|
||||||
|
savingsProductName?: string;
|
||||||
|
productName?: string;
|
||||||
|
fdType?: string;
|
||||||
|
maturityDate: string;
|
||||||
|
depositAmount?: number;
|
||||||
|
maturityAmount?: number;
|
||||||
|
onClosureType?: number;
|
||||||
|
linkedAccountId?: number;
|
||||||
|
renewalGracePeriodDays?: number;
|
||||||
|
},
|
||||||
|
today: string
|
||||||
|
): FdMaturityRow | null {
|
||||||
|
if (!fd.maturityDate) {
|
||||||
|
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
|
||||||
|
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const daysRemaining = daysBetween(today, fd.maturityDate);
|
||||||
|
const stage = maturityStage(daysRemaining);
|
||||||
|
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
|
||||||
|
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
|
||||||
|
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
|
||||||
|
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
|
||||||
|
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
|
||||||
|
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
|
||||||
|
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
|
||||||
|
// grace period (the grace window never delays it), so it's never renewable here either way.
|
||||||
|
const isReinvestEligible =
|
||||||
|
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
|
||||||
|
const renewable = stage === 'MATURED' && isReinvestEligible;
|
||||||
|
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
|
||||||
|
const configuredInstructionLabel =
|
||||||
|
stage === 'MATURED' && !renewable && fd.onClosureType != null
|
||||||
|
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fd.id,
|
||||||
|
accountNo: fd.accountNo || '',
|
||||||
|
clientId: fd.clientId ?? null,
|
||||||
|
clientName: fd.clientName || '—',
|
||||||
|
productName: fd.productName || fd.savingsProductName || '—',
|
||||||
|
maturityDate: fd.maturityDate,
|
||||||
|
depositAmount: fd.depositAmount ?? null,
|
||||||
|
maturityAmount: fd.maturityAmount ?? null,
|
||||||
|
daysRemaining,
|
||||||
|
daysOverdue: daysOverdueFor(daysRemaining),
|
||||||
|
stage,
|
||||||
|
meta: FD_MATURITY_STAGES[stage],
|
||||||
|
renewalGracePeriodDays: gracePeriodDays,
|
||||||
|
linkedAccountId: fd.linkedAccountId ?? null,
|
||||||
|
onClosureType: fd.onClosureType ?? null,
|
||||||
|
isReinvestEligible,
|
||||||
|
renewable,
|
||||||
|
withinGraceWindow,
|
||||||
|
configuredInstructionLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysOverdueFor(daysRemaining: number): number {
|
||||||
|
return daysRemaining < 0 ? -daysRemaining : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FdMaturitySummary {
|
||||||
|
total: number;
|
||||||
|
matured: number;
|
||||||
|
dueSoon: number;
|
||||||
|
upcoming: number;
|
||||||
|
onTrack: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
matured: rows.filter((r) => r.stage === 'MATURED').length,
|
||||||
|
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
|
||||||
|
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
|
||||||
|
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
|
||||||
|
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
|
||||||
|
const rank: Record<FdMaturityStage, number> = {
|
||||||
|
MATURED: 0,
|
||||||
|
DUE_SOON: 1,
|
||||||
|
UPCOMING: 2,
|
||||||
|
ON_TRACK: 3
|
||||||
|
};
|
||||||
|
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { FdDashboardRoutingModule } from './fd-dashboard-routing.module';
|
||||||
|
import { FdDashboardComponent } from './fd-dashboard.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
FdDashboardRoutingModule,
|
||||||
|
FdDashboardComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FdDashboardModule {}
|
||||||
Reference in New Issue
Block a user