/** * 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`; } }