feat(npa): add branch-wise NPA module with realistic data, pagination and recovery actions

Adds a full NPA (Non-Performing Assets) module mirroring the legacy CBS NPA
mega-menu: NPA Reports, NPA Analysis, and NPA Classification screens over a
640-account loan book classified per RBI IRAC norms (Standard through
Loss). Same Head-Office/branch-wise access pattern as the Cash/Clearing/
Transfer summary, 20-row pagination on every large table, and a Recovery
Action workflow (demand notice, SARFAESI, suit filing, OTS) on the Account
Detail screen.

Fixes several data-generation bugs so every account stays internally
consistent regardless of when it's viewed: disbursement date can no longer
predate the account's own overdue history, last-payment date now reflects
the most recent recovery entry, the quarter-ago snapshot can't imply more
overdue days than the loan's age allowed, and provision held is capped at
the account's outstanding balance.

Also fixes a text-overflow/dropdown-arrow overlap on long select options
(Investment + NPA shared form styles), and marks the GL "Financial
Statements" menu items that are already wired to a working dialog.
This commit is contained in:
2026-08-01 11:18:02 +05:30
parent 63ac900e42
commit 4261f9659e
10 changed files with 2312 additions and 9 deletions
@@ -0,0 +1,949 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CREDIT · NON-PERFORMING ASSETS · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">{{ reportLabel(activeReport) }}</h1>
<p class="pg-sub">
RBI IRAC-norm classification (Standard → SMA-0/1/2 → Sub-Standard → Doubtful-1/2/3 → Loss) across
{{ allAccounts.length }} of the bank's {{ bankWideTotalAccounts }} loan accounts, as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
<label class="pg-field">
<span>As On</span>
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
</div>
</header>
<div class="inv-kpis npa-kpis--ratio">
<div class="inv-kpi">
<span class="inv-kpi__label">Total Advances</span>
<span class="inv-kpi__value">₹{{ totalAdvances | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ allAccounts.length }} accounts</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Gross NPA (GNPA)</span>
<span class="inv-kpi__value npa-warn">₹{{ gnpaAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub"
>{{ npaAccounts.length }} accounts · {{ gnpaRatio | number: '1.2-2' }}% of advances</span
>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Net NPA (NNPA)</span>
<span class="inv-kpi__value npa-warn">₹{{ netNpaAmount | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ nnpaRatio | number: '1.2-2' }}% of advances</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Provision Held (NPA)</span>
<span class="inv-kpi__value">₹{{ totalNpaProvisionHeld | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">Recovered to date ₹{{ totalRecoveredAmount | number: '1.0-0' }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Restructured Accounts</span>
<span class="inv-kpi__value">{{ restructuredAccounts.length }}</span>
<span class="inv-kpi__sub">₹{{ restructuredOutstanding | number: '1.0-0' }} outstanding</span>
</div>
</div>
<div class="cctr-tabs">
@for (g of ['reports', 'analysis', 'classification']; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === g"
(click)="goToReport(groupOf(g)[0].id)"
>
{{ groupLabels[g] }}
</button>
}
</div>
<div class="cctr-tabs">
@for (m of groupOf(activeGroup); track m.id) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === m.id"
(click)="goToReport(m.id)"
>
{{ m.label }}
</button>
}
</div>
<!-- ===================== 01 NPA INTEREST ===================== -->
@if (activeReport === 'interest') {
<div class="pg-card">
<div class="pos-section-label">NPA Interest — Income Reversed on Non-Performing Accounts</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Rate</th>
<th class="num">Overdue Days</th>
<th class="num col-out">Interest Reversed</th>
</tr>
</thead>
<tbody>
@for (a of paged(npaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">{{ a.interestRate }}%</td>
<td class="num">{{ a.overdueDays }}</td>
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="8">Total Interest Reversed ({{ npaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumInterest(npaAccounts) | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@if (npaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, npaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(npaAccounts)"
(click)="goToPage(currentPage + 1, npaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 02 NPA STATUS REPORT ===================== -->
@if (activeReport === 'status') {
<div class="pg-card">
<div class="pos-section-label">NPA Status Report — Full Non-Performing Book</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Secured</th>
<th class="num">Outstanding</th>
<th class="num">Overdue Amount</th>
<th class="num">Overdue Days</th>
<th>NPA Since</th>
<th>Stage</th>
<th>Restructured</th>
<th>Legal Action</th>
<th class="num col-out">Provision Required</th>
</tr>
</thead>
<tbody>
@for (a of paged(npaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>{{ a.secured ? 'Yes' : 'No' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
<td>{{ npaSince(a) }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ a.isRestructured ? 'Yes' : 'No' }}</td>
<td [class]="legalActionClass(a.legalActionStatus)">{{ legalActionLabel(a.legalActionStatus) }}</td>
<td class="num col-out">₹{{ provisionReq(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="13" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (npaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, npaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(npaAccounts)"
(click)="goToPage(currentPage + 1, npaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 03 NPA RECOVERY REPORT ===================== -->
@if (activeReport === 'recovery') {
<div class="pg-card">
<div class="pos-section-label">Record a Recovery Against an NPA Account</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* NPA Account</span>
<select [(ngModel)]="newRecovery.accountNo">
<option value="">-- Select --</option>
@for (a of recoveryOptions; track a.accountNo) {
<option [value]="a.accountNo">
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Recovery Date</span>
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
</label>
<label class="inv-form-field">
<span>* Recovery Amount</span>
<input type="number" [(ngModel)]="newRecovery.amount" />
</label>
<label class="inv-form-field">
<span>Mode</span>
<select [(ngModel)]="newRecovery.mode">
<option>Cash Deposit</option>
<option>Cheque</option>
<option>NEFT/RTGS</option>
<option>Auction / SARFAESI</option>
<option>One-Time Settlement</option>
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addRecovery()">Save Recovery Entry</button>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0'
}}
GNPA outstanding
</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Mode</th>
<th class="num col-in">Recovered Amount</th>
</tr>
</thead>
<tbody>
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
<tr>
<td>{{ r.date }}</td>
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
</td>
<td>{{ r.customerName }}</td>
<td>{{ r.branchName }}</td>
<td>{{ r.mode }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Recovered</td>
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@if (recoveryRows.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(recoveryRows) }}–{{ rangeEnd(recoveryRows) }} of {{ recoveryRows.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, recoveryRows)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(recoveryRows) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(recoveryRows)"
(click)="goToPage(currentPage + 1, recoveryRows)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 04 NPA SUMMARY REPORT ===================== -->
@if (activeReport === 'summary') {
<div class="pg-card">
<div class="pos-section-label">Stage-wise Summary — {{ scopeName }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Classification</th>
<th class="num">Accounts</th>
<th class="num">Outstanding</th>
<th class="num col-out">Provision Required</th>
</tr>
</thead>
<tbody>
@for (row of stageSummary; track row.stage) {
<tr>
<td>
<span [class]="badgeClass(row.stage)">{{ row.label }}</span>
</td>
<td class="num">{{ row.count }}</td>
<td class="num">₹{{ row.outstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.provisionReq | number: '1.0-0' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ grandTotalAccounts }}</td>
<td class="num">₹{{ grandTotalOutstanding | number: '1.0-0' }}</td>
<td class="num">₹{{ grandTotalProvision | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (isHeadOffice && !branchFilter) {
<div class="pg-card">
<div class="pos-section-label">Branch-wise NPA Summary — All Branches</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Total Accounts</th>
<th class="num">Total Advances</th>
<th class="num">NPA Accounts</th>
<th class="num col-out">GNPA Outstanding</th>
<th class="num col-out">Provision Required</th>
<th class="num">GNPA %</th>
</tr>
</thead>
<tbody>
@for (row of branchSummary; track row.branchCode) {
<tr>
<td>
<a href="javascript:void(0)" (click)="setBranch(row.branchCode)">{{ row.branchName }}</a>
</td>
<td class="num">{{ row.totalAccounts }}</td>
<td class="num">₹{{ row.totalAdvances | number: '1.0-0' }}</td>
<td class="num">{{ row.npaCount }}</td>
<td class="num col-out">₹{{ row.npaOutstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ row.provisionReq | number: '1.0-0' }}</td>
<td class="num">{{ row.gnpaRatio | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== 05 NPA ACCOUNT DETAIL REPORT ===================== -->
@if (activeReport === 'account-detail') {
<div class="pg-card">
<div class="pos-section-label">Search NPA Account</div>
<div class="inv-form-grid">
<label class="inv-form-field npa-form-field--full">
<span>Account No / Customer</span>
<select [(ngModel)]="selectedAccountNo">
@for (a of npaAccounts; track a.accountNo) {
<option [value]="a.accountNo">{{ a.accountNo }} — {{ a.customerName }} ({{ a.branchName }})</option>
}
</select>
</label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="searchAccount()">View Details</button>
</div>
</div>
@if (selectedAccount; as a) {
<div class="pg-card">
<div class="pos-section-label">
{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</div>
<dl class="npa-detail-grid">
<div class="npa-detail-field">
<dt>Branch</dt>
<dd>{{ a.branchName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Product</dt>
<dd>{{ a.productName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Secured</dt>
<dd>{{ a.secured ? 'Yes' : 'No' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Guarantor</dt>
<dd>{{ a.guarantorName }}</dd>
</div>
<div class="npa-detail-field">
<dt>Sanctioned Amount</dt>
<dd>₹{{ a.sanctionedAmount | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Disbursement Date</dt>
<dd>{{ a.disbursementDate }}</dd>
</div>
<div class="npa-detail-field">
<dt>Interest Rate</dt>
<dd>{{ a.interestRate }}%</dd>
</div>
<div class="npa-detail-field">
<dt>Outstanding Balance</dt>
<dd>₹{{ a.outstandingBalance | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Overdue Amount</dt>
<dd>₹{{ a.overdueAmount | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Overdue Days</dt>
<dd>{{ a.overdueDays }}</dd>
</div>
<div class="npa-detail-field">
<dt>NPA Since</dt>
<dd>{{ npaSince(a) }}</dd>
</div>
<div class="npa-detail-field">
<dt>Restructured</dt>
<dd>{{ a.isRestructured ? 'Yes' : 'No' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Last Payment Date</dt>
<dd>{{ a.lastPaymentDate }}</dd>
</div>
<div class="npa-detail-field">
<dt>Collateral Value</dt>
<dd>₹{{ a.collateralValue | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Provisioning Rate</dt>
<dd>{{ provisionRate(a) }}%</dd>
</div>
<div class="npa-detail-field">
<dt>Provision Required</dt>
<dd>₹{{ provisionReq(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Provision Held</dt>
<dd>₹{{ provisionHeldAmt(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Interest Reversed</dt>
<dd>₹{{ interestRev(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Total Recovered</dt>
<dd>₹{{ recovered(a) | number: '1.0-0' }}</dd>
</div>
<div class="npa-detail-field">
<dt>Movement (vs Last Quarter)</dt>
<dd>{{ movement(a) }}</dd>
</div>
<div class="npa-detail-field">
<dt>NPA Reason</dt>
<dd>{{ a.npaReason }}</dd>
</div>
<div class="npa-detail-field">
<dt>Recovery Officer</dt>
<dd>{{ a.recoveryOfficer }}</dd>
</div>
<div class="npa-detail-field">
<dt>Legal Action Status</dt>
<dd [class]="legalActionClass(a.legalActionStatus)">{{ legalActionLabel(a.legalActionStatus) }}</dd>
</div>
</dl>
</div>
@if (isNpaStageOf(a)) {
<div class="pg-card">
<div class="pos-section-label">Recovery Action — Update Legal Action Status</div>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'NOTICE_ISSUED'"
(click)="updateLegalAction(a, 'NOTICE_ISSUED')"
>
Issue Demand Notice
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'SARFAESI_INITIATED'"
(click)="updateLegalAction(a, 'SARFAESI_INITIATED')"
>
Initiate SARFAESI Action
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'SUIT_FILED'"
(click)="updateLegalAction(a, 'SUIT_FILED')"
>
File Recovery Suit
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'OTS_NEGOTIATION'"
(click)="updateLegalAction(a, 'OTS_NEGOTIATION')"
>
Mark OTS Negotiation
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="a.legalActionStatus === 'NONE'"
(click)="updateLegalAction(a, 'NONE')"
>
Reset to No Action
</button>
</div>
</div>
}
<div class="pg-card">
<div class="pos-section-label">Recovery History</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Mode</th>
<th class="num col-in">Amount</th>
</tr>
</thead>
<tbody>
@for (r of a.recoveries; track r.date + r.amount) {
<tr>
<td>{{ r.date }}</td>
<td>{{ r.mode }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="pg-empty">No recoveries recorded against this account.</td>
</tr>
}
</tbody>
</table>
</div>
}
}
<!-- ===================== 06 NPA MOVEMENT REPORT ===================== -->
@if (activeReport === 'movement') {
<div class="pg-card">
<div class="pos-section-label">Classification Movement — Current Quarter vs Previous Quarter</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Previous Stage</th>
<th>Current Stage</th>
<th>Movement</th>
<th class="num">Outstanding</th>
</tr>
</thead>
<tbody>
@for (a of paged(movedAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>
<span [class]="badgeClass(prevStage(a))">{{ stageLabel(prevStage(a)) }}</span>
</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ movement(a) }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No classification movement in this branch.</td>
</tr>
}
</tbody>
</table>
@if (movedAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(movedAccounts) }}–{{ rangeEnd(movedAccounts) }} of {{ movedAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, movedAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(movedAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(movedAccounts)"
(click)="goToPage(currentPage + 1, movedAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 07 NPA WITH PROVISION AND REQUIRED PROVISION ===================== -->
@if (activeReport === 'provision') {
<div class="pg-card">
<div class="pos-section-label">Provision Held vs Required — Full Loan Book</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Rate</th>
<th class="num">Provision Required</th>
<th class="num">Provision Held</th>
<th class="num">Shortfall / (Excess)</th>
</tr>
</thead>
<tbody>
@for (a of paged(provisionRows); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">{{ provisionRate(a) }}%</td>
<td class="num">₹{{ provisionReq(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionHeldAmt(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="shortfall(a) > 0" [class.col-in]="shortfall(a) < 0">
₹{{ shortfall(a) | number: '1.0-0' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total ({{ provisionRows.length }} accounts)</td>
<td class="num">₹{{ provisionRequiredTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionHeldTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ provisionShortfallTotal | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
@if (provisionRows.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(provisionRows) }}–{{ rangeEnd(provisionRows) }} of {{ provisionRows.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, provisionRows)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(provisionRows) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(provisionRows)"
(click)="goToPage(currentPage + 1, provisionRows)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 08 PROBABLE NPA ===================== -->
@if (activeReport === 'probable') {
<div class="pg-card">
<div class="pos-section-label">
Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter (61–90 Days Overdue)
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Outstanding</th>
<th class="num">Overdue Amount</th>
<th class="num">Overdue Days</th>
<th>Last Payment</th>
<th>Follow-up</th>
</tr>
</thead>
<tbody>
@for (a of paged(probableNpaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
<td>{{ a.lastPaymentDate }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--sma2]="a.overdueDays > 75"
[class.npa-badge--sma0]="a.overdueDays <= 75"
>
{{ a.overdueDays > 75 ? 'Urgent' : 'Monitor' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No SMA-2 accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total ({{ probableNpaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumOutstanding(probableNpaAccounts) | number: '1.0-0' }}</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
@if (probableNpaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(probableNpaAccounts) }}–{{ rangeEnd(probableNpaAccounts) }} of
{{ probableNpaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, probableNpaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(probableNpaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(probableNpaAccounts)"
(click)="goToPage(currentPage + 1, probableNpaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 09 SMA REPORT ===================== -->
@if (activeReport === 'sma') {
<div class="pg-card">
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Overdue Amount</th>
<th class="num">Overdue Days</th>
</tr>
</thead>
<tbody>
@for (a of paged(smaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num">{{ a.overdueDays }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No SMA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total ({{ smaAccounts.length }} accounts)</td>
<td class="num">₹{{ sumOutstanding(smaAccounts) | number: '1.0-0' }}</td>
<td class="num">₹{{ sumOverdue(smaAccounts) | number: '1.0-0' }}</td>
<td></td>
</tr>
</tfoot>
</table>
@if (smaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(smaAccounts) }}–{{ rangeEnd(smaAccounts) }} of {{ smaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, smaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(smaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(smaAccounts)"
(click)="goToPage(currentPage + 1, smaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,490 @@
/**
* 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 { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
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,
todayDDMMYYYY,
LegalActionStatus,
LEGAL_ACTION_LABELS
} 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;
date: string;
amount: number;
mode: string;
}
/**
* The legacy "NPA" mega-menu — 9 screens across NPA Reports / NPA Analysis / NPA Classification —
* 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
]
})
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 = '';
newRecovery = {
accountNo: '',
date: this.asOnDate,
amount: null as number | null,
mode: 'Cash Deposit'
};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | 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<T>(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);
}
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);
}
get recoveryOptions(): LoanAccount[] {
return this.npaAccounts;
}
get recoveryRows(): RecoveryRow[] {
return this.recoveryAccounts
.flatMap((a) =>
a.recoveries.map((r) => ({
accountNo: a.accountNo,
customerName: a.customerName,
branchName: a.branchName,
date: r.date,
amount: r.amount,
mode: r.mode
}))
)
.sort((a, b) => b.amount - a.amount);
}
// -------------------- 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);
}
get restructuredAccounts(): LoanAccount[] {
return this.allAccounts.filter((a) => a.isRestructured);
}
get restructuredOutstanding(): number {
return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
get stageSummary(): StageSummaryRow[] {
return STAGE_ORDER.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.allAccounts.length;
}
get grandTotalOutstanding(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
get grandTotalProvision(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 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 {
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}.`);
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}