/** * 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 { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { Subscription } from 'rxjs'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export'; import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component'; import { LOAN_ACCOUNTS, BRANCHES, REPORT_META, GROUP_LABELS, DEFAULT_REPORT_ID, LoanAccount, NpaStage, ReportGroupId, STAGE_ORDER, STAGE_LABELS, currentStage, previousStage, isNpaStage, isSmaStage, provisionRatePct, provisionRequired, provisionHeld, interestReversed, totalRecovered, movementLabel, stageBadgeClass, npaSinceDate, daysSinceNpa, interestRecoveredCash, undebitedInterestBalance, todayDDMMYYYY, LegalActionStatus, LEGAL_ACTION_LABELS, RepaymentType, REPAYMENT_TYPE_LABELS, isBulletMatured, isBulletOverdue, isOverdrawnBreach, expectedRecoveryThisMonth, actualRecoveryThisMonth, securityCoverageRatio, isUndersecured, RecoveryEvent, RecoveryEventType } from '../npa-data'; interface StageSummaryRow { stage: NpaStage; label: string; count: number; outstanding: number; provisionReq: number; } interface BranchSummaryRow { branchCode: string; branchName: string; totalAccounts: number; totalAdvances: number; npaCount: number; npaOutstanding: number; provisionReq: number; gnpaRatio: number; } interface RecoveryRow { accountNo: string; customerName: string; branchName: string; productName: string; npaCategory: string; npaCategoryClass: string; date: string; amount: number; mode: string; /** What the account was contractually due to pay this month (EMI / interest-servicing / * bullet), the same "Target" basis used by the Recovery Management Dashboard — shown * alongside the actual recovered amount so a desk can see over/under-achievement per entry. */ targetRecovery: number; } interface RepaymentTypeSummaryRow { type: RepaymentType; label: string; accounts: number; overdueCount: number; expected: number; actual: number; } interface AgeingBucketRow { label: string; count: number; outstanding: number; overdueAmt: number; } /** * The legacy "NPA" mega-menu — screens across NPA Reports / NPA Analysis / NPA Classification / * Recovery Management — collapsed into one route-param-driven component so every menu item lands * on its own URL (/npa/reports/:reportId) with nothing left unwired. Every figure is derived from * the single LOAN_ACCOUNTS book in npa-data.ts, so a count on one report always agrees with another. */ @Component({ selector: 'mifosx-npa-reports', templateUrl: './npa-reports.component.html', styleUrls: [ '../../cashier/cashier-shared.scss', '../../investment/investment-shared.scss', '../npa-shared.scss' ], standalone: true, imports: [ CommonModule, FormsModule, RouterModule, FontAwesomeModule, SearchSelectComponent ] }) export class NpaReportsComponent implements OnInit, OnDestroy { readonly reportMeta = REPORT_META; readonly groupLabels = GROUP_LABELS; readonly branches = BRANCHES; readonly stageOrder = STAGE_ORDER; readonly stageLabels = STAGE_LABELS; asOnDate = todayDDMMYYYY(); activeGroup: ReportGroupId = 'reports'; activeReport = DEFAULT_REPORT_ID; /** True for a Head Office login — sees a branch selector + the consolidated "All Branches" * view with a branch-wise break-up. A branch-level login is locked to its own branch, the * same split used by the Cash/Clearing/Transfer Summary report. */ isHeadOffice = false; /** The logged-in user's own branch/office name, from real session credentials. */ currentOfficeName = 'Branch Office'; /** Set when a branch-level login's officeName matches one of the demo branches below. */ private ownBranchCode: string | null = null; /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ branchFilter = ''; /** Pagination — shared across whichever report is currently on screen, since only one * report's table is ever visible at a time. Reset to page 1 whenever the underlying list * can change size (report switch, branch switch, Generate). */ readonly pageSize = 20; currentPage = 1; selectedAccountNo = ''; /** Opens the Recovery / Collection Journey popup for whichever account is currently selected. */ showTimelineModal = false; newRecovery = { accountNo: '', date: this.asOnDate, amount: null as number | null, mode: 'Cash Deposit' }; /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */ generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; private sub?: Subscription; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly auth = inject(AuthenticationService); ngOnInit(): void { this.sub = this.route.paramMap.subscribe((params) => { const id = params.get('reportId') || DEFAULT_REPORT_ID; const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[3]; this.activeReport = meta.id; this.activeGroup = meta.group; this.currentPage = 1; }); const creds = this.auth.getCredentials(); this.isHeadOffice = this.auth.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; if (this.isHeadOffice) { // Head Office starts on the consolidated view and can drill into any branch. this.branchFilter = ''; } else { // Match the logged-in office's real name to one of the demo branches, so each branch's // login sees only its own book. Falls back to the first demo branch (still labelled with // the user's real office name) if no branch name matches. this.ownBranchCode = this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null; this.branchFilter = this.ownBranchCode || this.branches[0].code; } const firstNpa = this.npaAccounts[0]; this.selectedAccountNo = firstNpa ? firstNpa.accountNo : this.allAccounts[0]?.accountNo || ''; } ngOnDestroy(): void { this.sub?.unsubscribe(); } /** Name of the branch/scope currently being viewed, for display in the header. */ get scopeName(): string { if (!this.isHeadOffice) { return this.currentOfficeName; } return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches (Consolidated)'; } setBranch(code: string): void { this.branchFilter = code; this.currentPage = 1; } // -------------------- Pagination (shared by every report's table) -------------------- paged(items: T[]): T[] { const start = (this.currentPage - 1) * this.pageSize; return items.slice(start, start + this.pageSize); } pageCount(items: unknown[]): number { return Math.max(1, Math.ceil(items.length / this.pageSize)); } rangeStart(items: unknown[]): number { return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1; } rangeEnd(items: unknown[]): number { return Math.min(this.currentPage * this.pageSize, items.length); } goToPage(page: number, items: unknown[]): void { this.currentPage = Math.min(Math.max(1, page), this.pageCount(items)); } goToReport(reportId: string): void { this.router.navigate([ '/npa/reports', reportId ]); } groupOf(group: string) { return this.reportMeta.filter((m) => m.group === group); } reportLabel(id: string): string { return this.reportMeta.find((m) => m.id === id)?.label || id; } stage(a: LoanAccount): NpaStage { return currentStage(a); } prevStage(a: LoanAccount): NpaStage { return previousStage(a); } stageLabel(stage: NpaStage): string { return STAGE_LABELS[stage]; } badgeClass(stage: NpaStage): string { return stageBadgeClass(stage); } provisionRate(a: LoanAccount): number { return provisionRatePct(this.stage(a), a.secured); } provisionReq(a: LoanAccount): number { return provisionRequired(a); } provisionHeldAmt(a: LoanAccount): number { return provisionHeld(a); } interestRev(a: LoanAccount): number { return interestReversed(a); } recovered(a: LoanAccount): number { return totalRecovered(a); } movement(a: LoanAccount): string { return movementLabel(this.prevStage(a), this.stage(a)); } npaSince(a: LoanAccount): string { return npaSinceDate(a); } daysSinceNpaOf(a: LoanAccount): number { return daysSinceNpa(a); } interestRecoveredCashOf(a: LoanAccount): number { return interestRecoveredCash(a); } undebitedInterestBalanceOf(a: LoanAccount): number { return undebitedInterestBalance(a); } // -------------------- Recovery Management System -------------------- repaymentTypeLabel(type: RepaymentType): string { return REPAYMENT_TYPE_LABELS[type]; } expectedThisMonth(a: LoanAccount): number { return expectedRecoveryThisMonth(a); } actualThisMonth(a: LoanAccount): number { return actualRecoveryThisMonth(a); } recoveryVariance(a: LoanAccount): number { return this.actualThisMonth(a) - this.expectedThisMonth(a); } /** The per-repayment-type "is this account currently irregular" check the note calls for: * an EMI shortfall this cycle, a drawing-power breach, or a matured-unpaid bullet loan. */ isTypeOverdue(a: LoanAccount): boolean { if (a.repaymentType === 'OVERDRAWN') { return isOverdrawnBreach(a); } if (a.repaymentType === 'BULLET') { return isBulletOverdue(a); } return a.overdueDays > 0; } overdrawnBreachOf(a: LoanAccount): boolean { return isOverdrawnBreach(a); } excessOverDp(a: LoanAccount): number { return Math.max(0, a.outstandingBalance - a.drawingPower); } bulletMaturedOf(a: LoanAccount): boolean { return isBulletMatured(a); } bulletOverdueOf(a: LoanAccount): boolean { return isBulletOverdue(a); } coverageRatio(a: LoanAccount): number { return securityCoverageRatio(a); } undersecuredOf(a: LoanAccount): boolean { return isUndersecured(a); } securityShortfall(a: LoanAccount): number { return Math.max(0, a.outstandingBalance - a.collateralMarketValue); } /** CSS class for a collection-timeline event — "Payment Received" reads positive, formal * escalation steps (notice/agent) read as an alert, everything else is neutral follow-up. */ /** Groups a timeline event into one of 3 visual tones for the popup's icon badge — * informational follow-up, an escalation step, or an actual payment. */ timelineEventTone(type: RecoveryEventType): 'positive' | 'alert' | 'neutral' { if (type === 'Payment Received') { return 'positive'; } if (type === 'Formal Overdue Letter' || type === 'Recovery Agent Assigned' || type === 'Demand Notice') { return 'alert'; } return 'neutral'; } timelineEventIcon(type: RecoveryEventType): string { switch (type) { case 'SMS Reminder': return 'comment-dots'; case 'IVR / Auto Call': case 'Tele-calling': case 'Overdue Call': return 'phone'; case 'Field Visit': return 'map-marker-alt'; case 'Formal Overdue Letter': return 'envelope'; case 'Recovery Agent Assigned': return 'user-tie'; case 'Demand Notice': return 'file-signature'; case 'Payment Received': return 'check-circle'; } } openTimelineModal(): void { this.showTimelineModal = true; } closeTimelineModal(): void { this.showTimelineModal = false; } legalActionLabel(status: LegalActionStatus): string { return LEGAL_ACTION_LABELS[status]; } legalActionClass(status: LegalActionStatus): string { return status === 'SARFAESI_INITIATED' || status === 'SUIT_FILED' ? 'col-out' : ''; } updateLegalAction(a: LoanAccount, status: LegalActionStatus): void { a.legalActionStatus = status; this.toast(`${this.legalActionLabel(status)} — recorded for ${a.accountNo}.`); } isNpaStageOf(a: LoanAccount): boolean { return isNpaStage(this.stage(a)); } get allAccounts(): LoanAccount[] { return this.branchFilter ? LOAN_ACCOUNTS.filter((a) => a.branchCode === this.branchFilter) : LOAN_ACCOUNTS; } get npaAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => isNpaStage(this.stage(a))); } get smaAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => isSmaStage(this.stage(a))); } get probableNpaAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => this.stage(a) === 'SMA2').sort((a, b) => b.overdueDays - a.overdueDays); } get movedAccounts(): LoanAccount[] { return this.allAccounts .filter((a) => this.prevStage(a) !== this.stage(a)) .sort((a, b) => STAGE_ORDER.indexOf(this.stage(b)) - STAGE_ORDER.indexOf(this.stage(a))); } get recoveryAccounts(): LoanAccount[] { return this.npaAccounts.filter((a) => a.recoveries.length > 0); } /** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so * it's quicker to find the right account when adding a new entry) and the Recovery Register * below — a single control instead of two that could drift out of sync. */ recoveryProductFilter = ''; get recoveryProductOptions(): string[] { return [...new Set(this.npaAccounts.map((a) => a.productName))].sort(); } setRecoveryProductFilter(productName: string): void { this.recoveryProductFilter = productName; this.currentPage = 1; if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) { this.newRecovery.accountNo = ''; } } /** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */ get recoveryOptions(): LoanAccount[] { return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter); } /** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no, * customer name and branch, not just the collapsed label text. */ get recoveryAccountSearchOptions(): SearchSelectOption[] { return this.recoveryOptions.map((a) => ({ value: a.accountNo, label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`, searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}` })); } get recoveryRows(): RecoveryRow[] { return this.recoveryAccounts .filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter) .flatMap((a) => a.recoveries.map((r) => ({ accountNo: a.accountNo, customerName: a.customerName, branchName: a.branchName, productName: a.productName, npaCategory: this.stageLabel(this.stage(a)), npaCategoryClass: this.badgeClass(this.stage(a)), date: r.date, amount: r.amount, mode: r.mode, targetRecovery: expectedRecoveryThisMonth(a) })) ) .sort((a, b) => b.amount - a.amount); } /** The accounts behind recoveryRows, honouring the Account Type filter — used to total the * monthly Target once per account rather than once per recovery transaction (an account with * two recovery entries must not have its target counted twice). */ get recoveryFilteredAccounts(): LoanAccount[] { return this.recoveryAccounts.filter( (a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter ); } get recoveryTargetTotal(): number { return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0); } get recoveryActualTotal(): number { return this.recoveryRows.reduce((s, r) => s + r.amount, 0); } // -------------------- Portfolio-wide KPIs (shown on every screen) -------------------- get totalAdvances(): number { return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0); } get gnpaAmount(): number { return this.npaAccounts.reduce((s, a) => s + a.outstandingBalance, 0); } get gnpaRatio(): number { return this.totalAdvances ? (this.gnpaAmount / this.totalAdvances) * 100 : 0; } get totalNpaProvisionHeld(): number { return this.npaAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0); } get netNpaAmount(): number { return Math.max(0, this.gnpaAmount - this.totalNpaProvisionHeld); } get nnpaRatio(): number { return this.totalAdvances ? (this.netNpaAmount / this.totalAdvances) * 100 : 0; } get totalRecoveredAmount(): number { return this.npaAccounts.reduce((s, a) => s + this.recovered(a), 0); } // -------------------- Undebited Interest Recovery Report -------------------- get undebitedInterestAccruedTotal(): number { return this.npaAccounts.reduce((s, a) => s + this.interestRev(a), 0); } get interestRecoveredCashTotal(): number { return this.npaAccounts.reduce((s, a) => s + this.interestRecoveredCashOf(a), 0); } get undebitedInterestBalanceTotal(): number { return this.npaAccounts.reduce((s, a) => s + this.undebitedInterestBalanceOf(a), 0); } get restructuredAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => a.isRestructured); } get restructuredOutstanding(): number { return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0); } // -------------------- Recovery Management Dashboard -------------------- get recoveryTypeSummary(): RepaymentTypeSummaryRow[] { const types: RepaymentType[] = [ 'EMI', 'OVERDRAWN', 'BULLET' ]; return types.map((type) => { const accts = this.allAccounts.filter((a) => a.repaymentType === type); return { type, label: REPAYMENT_TYPE_LABELS[type], accounts: accts.length, overdueCount: accts.filter((a) => this.isTypeOverdue(a)).length, expected: accts.reduce((s, a) => s + this.expectedThisMonth(a), 0), actual: accts.reduce((s, a) => s + this.actualThisMonth(a), 0) }; }); } get expectedRecoveryTotal(): number { return this.allAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0); } get actualRecoveryTotal(): number { return this.allAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0); } get recoveryVarianceTotal(): number { return this.actualRecoveryTotal - this.expectedRecoveryTotal; } get recoveryPctTotal(): number { return this.expectedRecoveryTotal ? (this.actualRecoveryTotal / this.expectedRecoveryTotal) * 100 : 0; } get loanWiseRecoveryAccounts(): LoanAccount[] { return [...this.allAccounts].sort((a, b) => this.recoveryVariance(a) - this.recoveryVariance(b)); } // -------------------- Day-wise Overdue Report -------------------- get overdueAgeingBuckets(): AgeingBucketRow[] { const buckets: Array<{ label: string; test: (days: number) => boolean }> = [ { label: 'Current (0 Days)', test: (d) => d === 0 }, { label: '1–30 Days (SMA-0)', test: (d) => d >= 1 && d <= 30 }, { label: '31–60 Days (SMA-1)', test: (d) => d >= 31 && d <= 60 }, { label: '61–90 Days (SMA-2)', test: (d) => d >= 61 && d <= 90 }, { label: '90+ Days (NPA)', test: (d) => d > 90 } ]; return buckets.map((b) => { const accts = this.allAccounts.filter((a) => b.test(a.overdueDays)); return { label: b.label, count: accts.length, outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0), overdueAmt: accts.reduce((s, a) => s + a.overdueAmount, 0) }; }); } get overdueDaysAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => a.overdueDays > 0).sort((a, b) => b.overdueDays - a.overdueDays); } // -------------------- Day-wise Overdrawn Report -------------------- get overdrawnAccounts(): LoanAccount[] { return this.allAccounts .filter((a) => a.repaymentType === 'OVERDRAWN') .sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a)); } get overdrawnBreachCount(): number { return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length; } // -------------------- Agriculture / Gold Market Value Report -------------------- get agriGoldAccounts(): LoanAccount[] { return this.allAccounts .filter((a) => a.productCode === '05' || a.productCode === '06') .sort((a, b) => this.coverageRatio(a) - this.coverageRatio(b)); } // -------------------- Exception Check Report -------------------- get exceptionAccounts(): LoanAccount[] { return this.allAccounts .filter((a) => this.undersecuredOf(a)) .sort((a, b) => this.securityShortfall(b) - this.securityShortfall(a)); } // -------------------- NPA Summary Report: stage-wise + branch-wise cuts -------------------- /** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only * shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA * accounts still have their own dedicated Day-wise Overdue report. */ get stageSummary(): StageSummaryRow[] { return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => { const accts = this.allAccounts.filter((a) => this.stage(a) === stage); return { stage, label: STAGE_LABELS[stage], count: accts.length, outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0), provisionReq: accts.reduce((s, a) => s + this.provisionReq(a), 0) }; }); } get branchSummary(): BranchSummaryRow[] { return this.branches.map((b) => { const all = LOAN_ACCOUNTS.filter((a) => a.branchCode === b.code); const npa = all.filter((a) => isNpaStage(this.stage(a))); const totalAdvances = all.reduce((s, a) => s + a.outstandingBalance, 0); const npaOutstanding = npa.reduce((s, a) => s + a.outstandingBalance, 0); return { branchCode: b.code, branchName: b.name, totalAccounts: all.length, totalAdvances, npaCount: npa.length, npaOutstanding, provisionReq: npa.reduce((s, a) => s + this.provisionReq(a), 0), gnpaRatio: totalAdvances ? (npaOutstanding / totalAdvances) * 100 : 0 }; }); } get grandTotalAccounts(): number { return this.stageSummary.reduce((s, row) => s + row.count, 0); } get grandTotalOutstanding(): number { return this.stageSummary.reduce((s, row) => s + row.outstanding, 0); } get grandTotalProvision(): number { return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0); } get bankWideTotalAccounts(): number { return LOAN_ACCOUNTS.length; } // -------------------- NPA with Provision and Required Provision -------------------- get provisionRows(): LoanAccount[] { return [...this.allAccounts].sort((a, b) => this.provisionReq(b) - this.provisionReq(a)); } get provisionRequiredTotal(): number { return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0); } get provisionHeldTotal(): number { return this.allAccounts.reduce((s, a) => s + this.provisionHeldAmt(a), 0); } get provisionShortfallTotal(): number { return this.provisionRequiredTotal - this.provisionHeldTotal; } shortfall(a: LoanAccount): number { return this.provisionReq(a) - this.provisionHeldAmt(a); } // -------------------- NPA Account Detail Report -------------------- get selectedAccount(): LoanAccount | undefined { return LOAN_ACCOUNTS.find((a) => a.accountNo === this.selectedAccountNo); } searchAccount(): void { this.showTimelineModal = false; const a = this.selectedAccount; if (!a) { this.toast('No account found with that number.'); return; } this.toast(`Loaded full NPA details for ${a.accountNo} — ${a.customerName}.`); } // -------------------- NPA Recovery Report: add recovery entry -------------------- addRecovery(): void { const acc = LOAN_ACCOUNTS.find((a) => a.accountNo === this.newRecovery.accountNo); if (!acc || !this.newRecovery.amount || this.newRecovery.amount <= 0) { this.toast('Select an NPA account and enter a valid recovery amount.'); return; } acc.recoveries = [ { date: this.newRecovery.date || this.asOnDate, amount: this.newRecovery.amount, mode: this.newRecovery.mode }, ...acc.recoveries ]; this.toast(`Recovery of ₹${this.newRecovery.amount.toLocaleString('en-IN')} recorded against ${acc.accountNo}.`); this.newRecovery = { accountNo: '', date: this.asOnDate, amount: null, mode: 'Cash Deposit' }; } sumOutstanding(accounts: LoanAccount[]): number { return accounts.reduce((s, a) => s + a.outstandingBalance, 0); } sumOverdue(accounts: LoanAccount[]): number { return accounts.reduce((s, a) => s + a.overdueAmount, 0); } sumInterest(accounts: LoanAccount[]): number { return accounts.reduce((s, a) => s + this.interestRev(a), 0); } generate(): void { this.currentPage = 1; this.toast(`${this.reportLabel(this.activeReport)} generated as on ${this.asOnDate}.`); } // -------------------- Export to Excel (whichever report is currently on screen) -------------------- async exportExcel(): Promise { this.generating = true; try { const sheets = this.buildExportSheets(); await exportWorkbook(sheets, `${this.reportLabel(this.activeReport)}-${this.scopeName}-${this.asOnDate}`); } catch (err) { console.error('NPA export failed', err); } finally { this.generating = false; } } /** Builds the workbook for whichever report is active — every sheet is built from the same * derived getters the on-screen tables use, so the export can never disagree with the screen. */ private buildExportSheets(): ExportSheetSpec[] { const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`; switch (this.activeReport) { case 'interest': return [ { sheetName: 'NPA Report', title: 'NPA Report — Income Reversed on Non-Performing Accounts', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Stage', 'Outstanding', 'Rate %', 'Overdue Days', 'Interest Reversed' ], widths: [ 16, 22, 16, 16, 14, 16, 10, 12, 18 ], rows: [ ...this.npaAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, this.stageLabel(this.stage(a)), a.outstandingBalance, a.interestRate, a.overdueDays, this.interestRev(a) ]), [ `Total (${this.npaAccounts.length} accounts)`, '', '', '', '', '', '', '', this.sumInterest(this.npaAccounts) ] ] } ]; case 'status': return [ { sheetName: 'NPA Status', title: 'NPA Status Report — Full Non-Performing Book', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Secured', 'Outstanding', 'Overdue Amount', 'Overdue Days', 'NPA Since', 'Stage', 'Restructured', 'Legal Action', 'Provision Required' ], widths: [ 16, 22, 16, 16, 10, 16, 16, 12, 12, 14, 12, 22, 18 ], rows: this.npaAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, a.secured ? 'Yes' : 'No', a.outstandingBalance, a.overdueAmount, a.overdueDays, this.npaSince(a), this.stageLabel(this.stage(a)), a.isRestructured ? 'Yes' : 'No', this.legalActionLabel(a.legalActionStatus), this.provisionReq(a) ]) } ]; case 'recovery': return [ { sheetName: 'NPA Recovery', title: 'NPA Recovery Register', subtitle: this.recoveryProductFilter ? `${subtitle} · Account Type: ${this.recoveryProductFilter}` : subtitle, headers: [ 'Date', 'Account Type', 'NPA Category', 'Account No', 'Customer', 'Branch', 'Mode', 'Target Recovery', 'Recovered Amount (Actual)' ], widths: [ 12, 18, 14, 16, 22, 16, 18, 16, 20 ], rows: [ ...this.recoveryRows.map((r) => [ r.date, r.productName, r.npaCategory, r.accountNo, r.customerName, r.branchName, r.mode, r.targetRecovery, r.amount ]), [ 'Total (Target once per account · Actual across all entries)', '', '', '', '', '', '', this.recoveryTargetTotal, this.recoveryActualTotal ] ] } ]; case 'summary': { const sheets: ExportSheetSpec[] = [ { sheetName: 'Stage Summary', title: 'NPA Summary Report — Stage-wise', subtitle, headers: [ 'Classification', 'Accounts', 'Outstanding', 'Provision Required' ], widths: [ 22, 12, 18, 18 ], rows: [ ...this.stageSummary.map((row) => [ row.label, row.count, row.outstanding, row.provisionReq ]), [ 'Total', this.grandTotalAccounts, this.grandTotalOutstanding, this.grandTotalProvision ] ] } ]; if (this.isHeadOffice && !this.branchFilter) { sheets.push({ sheetName: 'Branch Summary', title: 'Branch-wise NPA Summary — All Branches', subtitle: `As On: ${this.asOnDate}`, headers: [ 'Branch', 'Total Accounts', 'Total Advances', 'NPA Accounts', 'GNPA Outstanding', 'Provision Required', 'GNPA %' ], widths: [ 20, 14, 18, 14, 18, 18, 10 ], rows: this.branchSummary.map((row) => [ row.branchName, row.totalAccounts, row.totalAdvances, row.npaCount, row.npaOutstanding, row.provisionReq, Number(row.gnpaRatio.toFixed(2)) ]) }); } return sheets; } case 'account-detail': { const a = this.selectedAccount; const detailRows: (string | number | null)[][] = a ? [ [ 'Account No', a.accountNo ], [ 'Customer', a.customerName ], [ 'Branch', a.branchName ], [ 'Product', a.productName ], [ 'Secured', a.secured ? 'Yes' : 'No' ], [ 'Guarantor', a.guarantorName ], [ 'Sanctioned Amount', a.sanctionedAmount ], [ 'Disbursement Date', a.disbursementDate ], [ 'Interest Rate %', a.interestRate ], [ 'Outstanding Balance', a.outstandingBalance ], [ 'Overdue Amount', a.overdueAmount ], [ 'Overdue Days', a.overdueDays ], [ 'NPA Since', this.npaSince(a) ], [ 'Restructured', a.isRestructured ? 'Yes' : 'No' ], [ 'Last Payment Date', a.lastPaymentDate ], [ 'Collateral Value', a.collateralValue ], [ 'Stage', this.stageLabel(this.stage(a)) ], [ 'Provisioning Rate %', this.provisionRate(a) ], [ 'Provision Required', this.provisionReq(a) ], [ 'Provision Held', this.provisionHeldAmt(a) ], [ 'Interest Reversed', this.interestRev(a) ], [ 'Total Recovered', this.recovered(a) ], [ 'Movement (vs Last Quarter)', this.movement(a) ], [ 'NPA Reason', a.npaReason ], [ 'Recovery Officer', a.recoveryOfficer ], [ 'Legal Action Status', this.legalActionLabel(a.legalActionStatus) ] ] : []; return [ { sheetName: 'Account Detail', title: a ? `${a.accountNo} — ${a.customerName}` : 'NPA Account Detail Report', subtitle, headers: [ 'Field', 'Value' ], widths: [ 26, 32 ], rows: detailRows }, { sheetName: 'Recovery History', title: a ? `Recovery History — ${a.accountNo}` : 'Recovery History', subtitle, headers: [ 'Date', 'Mode', 'Amount' ], widths: [ 14, 20, 18 ], rows: (a?.recoveries || []).map((r) => [ r.date, r.mode, r.amount ]) }, { sheetName: 'Collection Timeline', title: a ? `Recovery / Collection Journey — ${a.accountNo}` : 'Recovery / Collection Journey', subtitle, headers: [ 'Date', 'Event', 'Channel', 'Remarks' ], widths: [ 14, 20, 24, 46 ], rows: (a?.collectionTimeline || []).map((e) => [ e.date, e.type, e.channel, e.remarks ]) } ]; } case 'movement': return [ { sheetName: 'NPA Movement', title: 'Classification Movement — Current Quarter vs Previous Quarter', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Previous Stage', 'Current Stage', 'Movement', 'Outstanding' ], widths: [ 16, 22, 16, 16, 16, 26, 16 ], rows: this.movedAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, this.stageLabel(this.prevStage(a)), this.stageLabel(this.stage(a)), this.movement(a), a.outstandingBalance ]) } ]; case 'provision': return [ { sheetName: 'NPA Provision', title: 'Provision Held vs Required — Full Loan Book', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Stage', 'Outstanding', 'Rate %', 'Provision Required', 'Provision Held', 'Shortfall / (Excess)' ], widths: [ 16, 22, 16, 14, 16, 10, 18, 16, 18 ], rows: [ ...this.provisionRows.map((a) => [ a.accountNo, a.customerName, a.branchName, this.stageLabel(this.stage(a)), a.outstandingBalance, this.provisionRate(a), this.provisionReq(a), this.provisionHeldAmt(a), this.shortfall(a) ]), [ `Total (${this.provisionRows.length} accounts)`, '', '', '', '', '', this.provisionRequiredTotal, this.provisionHeldTotal, this.provisionShortfallTotal ] ] } ]; case 'undebited-interest': return [ { sheetName: 'Undebited Interest', title: 'Undebited Interest Recovery Report — Memorandum Interest (IRAC Norms)', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Stage', 'NPA Since', 'Days NPA', 'Rate %', 'Undebited Interest Accrued', 'Interest Recovered (Cash)', 'Undebited Interest Balance' ], widths: [ 16, 22, 16, 14, 12, 10, 10, 22, 20, 22 ], rows: [ ...this.npaAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, this.stageLabel(this.stage(a)), this.npaSince(a), this.daysSinceNpaOf(a), a.interestRate, this.interestRev(a), this.interestRecoveredCashOf(a), this.undebitedInterestBalanceOf(a) ]), [ `Total (${this.npaAccounts.length} accounts)`, '', '', '', '', '', '', this.undebitedInterestAccruedTotal, this.interestRecoveredCashTotal, this.undebitedInterestBalanceTotal ] ] } ]; case 'probable': return [ { sheetName: 'Probable NPA', title: 'Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Outstanding', 'Overdue Amount', 'Overdue Days', 'Last Payment', 'Follow-up' ], widths: [ 16, 22, 16, 16, 16, 16, 12, 14, 12 ], rows: [ ...this.probableNpaAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, a.outstandingBalance, a.overdueAmount, a.overdueDays, a.lastPaymentDate, a.overdueDays > 75 ? 'Urgent' : 'Monitor' ]), [ `Total (${this.probableNpaAccounts.length} accounts)`, '', '', '', this.sumOutstanding(this.probableNpaAccounts), '', '', '', '' ] ] } ]; case 'sma': return [ { sheetName: 'SMA Report', title: 'Special Mention Accounts — SMA-0 / SMA-1 / SMA-2', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Stage', 'Outstanding', 'Overdue Amount', 'Overdue Days' ], widths: [ 16, 22, 16, 16, 14, 16, 16, 12 ], rows: [ ...this.smaAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, this.stageLabel(this.stage(a)), a.outstandingBalance, a.overdueAmount, a.overdueDays ]), [ `Total (${this.smaAccounts.length} accounts)`, '', '', '', '', this.sumOutstanding(this.smaAccounts), this.sumOverdue(this.smaAccounts), '' ] ] } ]; case 'recovery-dashboard': return [ { sheetName: 'Recovery Summary', title: 'Expected vs Actual Recovery — This Cycle, by Loan Type', subtitle, headers: [ 'Loan Type', 'Accounts', 'Overdue Now', 'Expected Recovery', 'Actual Recovery', 'Variance' ], widths: [ 20, 12, 12, 18, 18, 16 ], rows: [ ...this.recoveryTypeSummary.map((row) => [ row.label, row.accounts, row.overdueCount, row.expected, row.actual, row.actual - row.expected ]), [ 'Total', this.allAccounts.length, this.recoveryTypeSummary.reduce((s, row) => s + row.overdueCount, 0), this.expectedRecoveryTotal, this.actualRecoveryTotal, this.recoveryVarianceTotal ] ] }, { sheetName: 'Loan-wise Recovery', title: 'Loan-wise Expected vs Actual Recovery', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Loan Type', 'Expected', 'Actual', 'Variance', 'Status' ], widths: [ 16, 22, 16, 18, 16, 16, 16, 12 ], rows: this.loanWiseRecoveryAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, this.repaymentTypeLabel(a.repaymentType), this.expectedThisMonth(a), this.actualThisMonth(a), this.recoveryVariance(a), this.recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' ]) } ]; case 'daywise-overdue': return [ { sheetName: 'Ageing Buckets', title: 'Day-wise Overdue Report — Ageing Buckets', subtitle, headers: [ 'Overdue Bucket', 'Accounts', 'Outstanding', 'Overdue Amount' ], widths: [ 22, 12, 18, 18 ], rows: this.overdueAgeingBuckets.map((b) => [ b.label, b.count, b.outstanding, b.overdueAmt ]) }, { sheetName: 'Overdue Accounts', title: 'Overdue Accounts — Day-wise, Marked by SMA Stage', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Stage', 'Overdue Days', 'Outstanding', 'Overdue Amount' ], widths: [ 16, 22, 16, 16, 14, 12, 16, 18 ], rows: this.overdueDaysAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, this.stageLabel(this.stage(a)), a.overdueDays, a.outstandingBalance, a.overdueAmount ]) } ]; case 'daywise-overdrawn': return [ { sheetName: 'Overdrawn Accounts', title: 'Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Drawing Power', 'Outstanding', 'Excess Over DP', 'Days Overdrawn', 'Status' ], widths: [ 16, 22, 16, 16, 16, 16, 16, 14, 14 ], rows: this.overdrawnAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, a.drawingPower, a.outstandingBalance, this.excessOverDp(a), this.overdrawnBreachOf(a) ? a.overdueDays : 0, this.overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' ]) } ]; case 'agri-gold-value': return [ { sheetName: 'Agri-Gold Value', title: 'Agriculture / Gold Market Value Report — Current Security Value vs Loan', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Outstanding', 'Collateral Value (At Sanction)', 'Market Value (Now)', 'Coverage %', 'Status' ], widths: [ 16, 22, 16, 16, 16, 22, 18, 12, 14 ], rows: this.agriGoldAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, a.outstandingBalance, a.collateralValue, a.collateralMarketValue, Number(this.coverageRatio(a).toFixed(0)), this.undersecuredOf(a) ? 'Undersecured' : 'Adequate' ]) } ]; case 'exception-check': return [ { sheetName: 'Exception Check', title: 'Exception Check — Security Value vs Loan', subtitle, headers: [ 'Account No', 'Customer', 'Branch', 'Product', 'Stage', 'Outstanding', 'Market Value', 'Shortfall', 'Coverage %' ], widths: [ 16, 22, 16, 16, 14, 16, 16, 16, 12 ], rows: this.exceptionAccounts.map((a) => [ a.accountNo, a.customerName, a.branchName, a.productName, this.stageLabel(this.stage(a)), a.outstandingBalance, a.collateralMarketValue, this.securityShortfall(a), Number(this.coverageRatio(a).toFixed(0)) ]) } ]; default: return []; } } private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; if (this.toastTimer) { clearTimeout(this.toastTimer); } this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); } }