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:
@@ -66,6 +66,7 @@ import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
FiuModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
@@ -1232,39 +1232,41 @@
|
||||
<!-- NPA Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Reports</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
|
||||
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
|
||||
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Analysis -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Analysis</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
|
||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Classification -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Classification</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
|
||||
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
|
||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
@@ -856,6 +856,20 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
|
||||
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
|
||||
// which items in a mega-menu are functional without having to click every one of them.
|
||||
.gl-link--live::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
|
||||
}
|
||||
|
||||
/* RBI Mega Menu */
|
||||
.rbi-mega-menu {
|
||||
width: 1100px;
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -155,6 +156,15 @@
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Long "code — name (branch)" style option text must never run under the native dropdown
|
||||
// arrow — truncate it with an ellipsis instead of letting the browser clip it mid-word.
|
||||
select {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Computed / lookup-filled fields are rendered read-only and greyed out so they read as
|
||||
@@ -168,6 +178,7 @@
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 0 18px 18px;
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Dummy NPA (Non-Performing Asset) data layer for the NPA Reports / Analysis / Classification
|
||||
// screens. Everything downstream — classification, provisioning, interest reversal, GNPA/NNPA —
|
||||
// is *derived* from LOAN_ACCOUNTS via pure functions below, so no two reports can ever disagree
|
||||
// on the same account's numbers.
|
||||
//
|
||||
// The book is generated with a seeded RNG (not Math.random()) so the "large, real-world-CBS"
|
||||
// dataset stays identical across reloads instead of reshuffling every time the page opens.
|
||||
|
||||
export type NpaStage =
|
||||
| 'STANDARD'
|
||||
| 'SMA0'
|
||||
| 'SMA1'
|
||||
| 'SMA2'
|
||||
| 'SUBSTANDARD'
|
||||
| 'DOUBTFUL1'
|
||||
| 'DOUBTFUL2'
|
||||
| 'DOUBTFUL3'
|
||||
| 'LOSS';
|
||||
|
||||
export const STAGE_ORDER: NpaStage[] = [
|
||||
'STANDARD',
|
||||
'SMA0',
|
||||
'SMA1',
|
||||
'SMA2',
|
||||
'SUBSTANDARD',
|
||||
'DOUBTFUL1',
|
||||
'DOUBTFUL2',
|
||||
'DOUBTFUL3',
|
||||
'LOSS'
|
||||
];
|
||||
|
||||
export const STAGE_LABELS: Record<NpaStage, string> = {
|
||||
STANDARD: 'Standard',
|
||||
SMA0: 'SMA-0',
|
||||
SMA1: 'SMA-1',
|
||||
SMA2: 'SMA-2',
|
||||
SUBSTANDARD: 'Sub-Standard',
|
||||
DOUBTFUL1: 'Doubtful-1',
|
||||
DOUBTFUL2: 'Doubtful-2',
|
||||
DOUBTFUL3: 'Doubtful-3',
|
||||
LOSS: 'Loss Asset'
|
||||
};
|
||||
|
||||
export function isNpaStage(stage: NpaStage): boolean {
|
||||
return stage !== 'STANDARD' && stage !== 'SMA0' && stage !== 'SMA1' && stage !== 'SMA2';
|
||||
}
|
||||
|
||||
export function isSmaStage(stage: NpaStage): boolean {
|
||||
return stage === 'SMA0' || stage === 'SMA1' || stage === 'SMA2';
|
||||
}
|
||||
|
||||
export function stageBadgeClass(stage: NpaStage): string {
|
||||
return `npa-badge npa-badge--${stage.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Recovery-workflow stage tracked separately from IRAC classification — a Doubtful/Loss
|
||||
* account can sit at any of these while its classification stays driven purely by overdueDays. */
|
||||
export type LegalActionStatus = 'NONE' | 'NOTICE_ISSUED' | 'SARFAESI_INITIATED' | 'SUIT_FILED' | 'OTS_NEGOTIATION';
|
||||
|
||||
export const LEGAL_ACTION_LABELS: Record<LegalActionStatus, string> = {
|
||||
NONE: 'No Action Yet',
|
||||
NOTICE_ISSUED: 'Demand Notice Issued',
|
||||
SARFAESI_INITIATED: 'SARFAESI Action Initiated',
|
||||
SUIT_FILED: 'Recovery Suit Filed',
|
||||
OTS_NEGOTIATION: 'One-Time Settlement Under Negotiation'
|
||||
};
|
||||
|
||||
export interface RecoveryTxn {
|
||||
date: string;
|
||||
amount: number;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export interface LoanAccount {
|
||||
accountNo: string;
|
||||
customerName: string;
|
||||
guarantorName: string;
|
||||
branchCode: string;
|
||||
branchName: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
secured: boolean;
|
||||
sanctionedAmount: number;
|
||||
disbursementDate: string;
|
||||
interestRate: number;
|
||||
outstandingBalance: number;
|
||||
overdueAmount: number;
|
||||
overdueDays: number;
|
||||
overdueDaysQuarterAgo: number;
|
||||
collateralValue: number;
|
||||
lastPaymentDate: string;
|
||||
isLossAsset: boolean;
|
||||
isRestructured: boolean;
|
||||
provisionCoverageRatio: number;
|
||||
recoveries: RecoveryTxn[];
|
||||
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
|
||||
npaReason: string;
|
||||
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
|
||||
recoveryOfficer: string;
|
||||
legalActionStatus: LegalActionStatus;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Date helpers
|
||||
// -------------------------------------------------------------------------
|
||||
function fmt(d: Date): string {
|
||||
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + days);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Parses a "DD-MM-YYYY" string back into a Date, so generated dates can be compared/ordered
|
||||
* against each other (e.g. "is this recovery more recent than the last-payment estimate?"). */
|
||||
function parseDMY(s: string): Date {
|
||||
const [
|
||||
d,
|
||||
m,
|
||||
y
|
||||
] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function todayDDMMYYYY(): string {
|
||||
return fmt(new Date());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Seeded RNG — keeps the generated book stable across reloads
|
||||
// -------------------------------------------------------------------------
|
||||
function mulberry32(seed: number): () => number {
|
||||
return function random(): number {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
const rng = mulberry32(19870614);
|
||||
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
||||
const between = (min: number, max: number): number => min + rng() * (max - min);
|
||||
const round = (n: number): number => Math.round(n);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Masters
|
||||
// -------------------------------------------------------------------------
|
||||
export interface BranchMaster {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
// BR01/BR02 are deliberately named "Branch 1"/"Branch 2" — identical to the two branches in
|
||||
// cash-clearing-transfer-summary.component.ts — so a single pair of real office names in the DB
|
||||
// gets recognised by BOTH reports instead of each module expecting a different branch name.
|
||||
export const BRANCHES: BranchMaster[] = [
|
||||
{ code: 'BR01', name: 'Branch 1' },
|
||||
{ code: 'BR02', name: 'Branch 2' },
|
||||
{ code: 'BR03', name: 'Borivali Branch' },
|
||||
{ code: 'BR04', name: 'Thane Branch' },
|
||||
{ code: 'BR05', name: 'Pune Branch' },
|
||||
{ code: 'BR06', name: 'Nashik Branch' },
|
||||
{ code: 'BR07', name: 'Nagpur Branch' },
|
||||
{ code: 'BR08', name: 'Kolhapur Branch' }
|
||||
];
|
||||
|
||||
interface ProductMaster {
|
||||
code: string;
|
||||
name: string;
|
||||
secured: boolean;
|
||||
minAmt: number;
|
||||
maxAmt: number;
|
||||
minRate: number;
|
||||
maxRate: number;
|
||||
}
|
||||
export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||
{
|
||||
code: '01',
|
||||
name: 'Cash Credit (MSME)',
|
||||
secured: true,
|
||||
minAmt: 500000,
|
||||
maxAmt: 5000000,
|
||||
minRate: 11,
|
||||
maxRate: 13.5
|
||||
},
|
||||
{ code: '02', name: 'Term Loan - MSME', secured: true, minAmt: 300000, maxAmt: 3000000, minRate: 11, maxRate: 14 },
|
||||
{ code: '03', name: 'Housing Loan', secured: true, minAmt: 1000000, maxAmt: 4000000, minRate: 8.5, maxRate: 10.5 },
|
||||
{ code: '04', name: 'Vehicle Loan', secured: true, minAmt: 300000, maxAmt: 1500000, minRate: 9, maxRate: 11.5 },
|
||||
{ code: '05', name: 'Gold Loan', secured: true, minAmt: 50000, maxAmt: 1000000, minRate: 9, maxRate: 12 },
|
||||
{ code: '06', name: 'Kisan Credit Card', secured: true, minAmt: 100000, maxAmt: 500000, minRate: 7, maxRate: 9 },
|
||||
{ code: '07', name: 'Personal Loan', secured: false, minAmt: 50000, maxAmt: 500000, minRate: 13, maxRate: 16 },
|
||||
{ code: '08', name: 'Education Loan', secured: false, minAmt: 200000, maxAmt: 1500000, minRate: 10, maxRate: 12 },
|
||||
{
|
||||
code: '09',
|
||||
name: 'SHG / Micro Finance Loan',
|
||||
secured: false,
|
||||
minAmt: 20000,
|
||||
maxAmt: 200000,
|
||||
minRate: 12,
|
||||
maxRate: 14
|
||||
},
|
||||
{
|
||||
code: '10',
|
||||
name: 'Overdraft Against Property',
|
||||
secured: true,
|
||||
minAmt: 500000,
|
||||
maxAmt: 2500000,
|
||||
minRate: 11,
|
||||
maxRate: 13
|
||||
}
|
||||
];
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Suresh',
|
||||
'Mahesh',
|
||||
'Ganesh',
|
||||
'Dinesh',
|
||||
'Vijay',
|
||||
'Sanjay',
|
||||
'Ajay',
|
||||
'Prakash',
|
||||
'Vinod',
|
||||
'Sunita',
|
||||
'Anita',
|
||||
'Kavita',
|
||||
'Savita',
|
||||
'Rekha',
|
||||
'Meena',
|
||||
'Pooja',
|
||||
'Sangita',
|
||||
'Nirmala',
|
||||
'Usha',
|
||||
'Ravindra',
|
||||
'Mahendra',
|
||||
'Narendra',
|
||||
'Jitendra',
|
||||
'Rajendra',
|
||||
'Bhagwan',
|
||||
'Balasaheb',
|
||||
'Vasant',
|
||||
'Shivaji',
|
||||
'Yogesh',
|
||||
'Kiran',
|
||||
'Manisha',
|
||||
'Deepak',
|
||||
'Sandeep',
|
||||
'Pradeep',
|
||||
'Ashok',
|
||||
'Vishal',
|
||||
'Nitin',
|
||||
'Amol',
|
||||
'Santosh'
|
||||
];
|
||||
const LAST_NAMES = [
|
||||
'Patil',
|
||||
'More',
|
||||
'Jadhav',
|
||||
'Deshmukh',
|
||||
'Pawar',
|
||||
'Kulkarni',
|
||||
'Shinde',
|
||||
'Gaikwad',
|
||||
'Chavan',
|
||||
'Bhosale',
|
||||
'Kale',
|
||||
'Salunkhe',
|
||||
'Sawant',
|
||||
'Kadam',
|
||||
'Joshi',
|
||||
'Wagh',
|
||||
'Thorat',
|
||||
'Waghmare',
|
||||
'Mane',
|
||||
'Shaikh'
|
||||
];
|
||||
|
||||
const NPA_REASONS = [
|
||||
'Crop Failure / Agri Distress',
|
||||
'Business Loss',
|
||||
'Health Issue / Death of Borrower',
|
||||
'Diversion of Funds',
|
||||
'Wilful Default',
|
||||
'Natural Calamity',
|
||||
'Loss of Employment',
|
||||
'Family Dispute / Litigation',
|
||||
'Delayed Receivables from Debtors',
|
||||
'Unit Closure'
|
||||
];
|
||||
|
||||
/** Recovery officers are bank staff, kept distinct from the FIRST_NAMES/LAST_NAMES pool used for
|
||||
* customers so a name never accidentally reads as "the officer is also the borrower". */
|
||||
const RECOVERY_OFFICERS = [
|
||||
'Anil Deshpande',
|
||||
'Sunil Kadam',
|
||||
'Vikas Pathak',
|
||||
'Rajendra More',
|
||||
'Sachin Bhagat',
|
||||
'Prashant Jagtap',
|
||||
'Milind Kulkarni',
|
||||
'Ravi Ghadge'
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Weighted stage buckets — used only to seed a realistic distribution of
|
||||
// overdue-day counts; the actual classification is always re-derived from
|
||||
// `overdueDays` via classifyDays(), never stored directly.
|
||||
// -------------------------------------------------------------------------
|
||||
interface StageBucket {
|
||||
weight: number;
|
||||
minDays: number;
|
||||
maxDays: number;
|
||||
isLossPool: boolean;
|
||||
}
|
||||
const BUCKETS: StageBucket[] = [
|
||||
{ weight: 60, minDays: 0, maxDays: 0, isLossPool: false },
|
||||
{ weight: 10, minDays: 1, maxDays: 30, isLossPool: false },
|
||||
{ weight: 7, minDays: 31, maxDays: 60, isLossPool: false },
|
||||
{ weight: 6, minDays: 61, maxDays: 90, isLossPool: false },
|
||||
{ weight: 9, minDays: 91, maxDays: 365, isLossPool: false },
|
||||
{ weight: 4, minDays: 366, maxDays: 730, isLossPool: false },
|
||||
{ weight: 2.5, minDays: 731, maxDays: 1095, isLossPool: false },
|
||||
{ weight: 1.5, minDays: 1096, maxDays: 1900, isLossPool: true }
|
||||
];
|
||||
const TOTAL_WEIGHT = BUCKETS.reduce((s, b) => s + b.weight, 0);
|
||||
|
||||
function pickBucket(): StageBucket {
|
||||
let r = rng() * TOTAL_WEIGHT;
|
||||
for (const b of BUCKETS) {
|
||||
if (r < b.weight) {
|
||||
return b;
|
||||
}
|
||||
r -= b.weight;
|
||||
}
|
||||
return BUCKETS[0];
|
||||
}
|
||||
|
||||
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
|
||||
* advanced/stressed the classification, the more likely the bank has already escalated. */
|
||||
function legalActionFor(stage: NpaStage): LegalActionStatus {
|
||||
if (!isNpaStage(stage)) {
|
||||
return 'NONE';
|
||||
}
|
||||
const r = rng();
|
||||
if (stage === 'SUBSTANDARD') {
|
||||
return r < 0.2 ? 'NOTICE_ISSUED' : 'NONE';
|
||||
}
|
||||
if (stage === 'DOUBTFUL1') {
|
||||
if (r < 0.15) return 'OTS_NEGOTIATION';
|
||||
if (r < 0.45) return 'SARFAESI_INITIATED';
|
||||
if (r < 0.65) return 'NOTICE_ISSUED';
|
||||
return 'NONE';
|
||||
}
|
||||
// DOUBTFUL2, DOUBTFUL3, LOSS
|
||||
if (r < 0.1) return 'OTS_NEGOTIATION';
|
||||
if (r < 0.55) return 'SUIT_FILED';
|
||||
if (r < 0.8) return 'SARFAESI_INITIATED';
|
||||
if (r < 0.9) return 'NOTICE_ISSUED';
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
function generateAccounts(count: number): LoanAccount[] {
|
||||
const today = new Date();
|
||||
const accounts: LoanAccount[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const branch = pick(BRANCHES);
|
||||
const product = pick(LOAN_PRODUCTS);
|
||||
const bucket = pickBucket();
|
||||
const overdueDays = bucket.maxDays === 0 ? 0 : round(between(bucket.minDays, bucket.maxDays));
|
||||
const isLossAsset = bucket.isLossPool && rng() < 0.4;
|
||||
// Restructured accounts skew towards the already-stressed buckets (SMA-2 and worse),
|
||||
// matching how RBI restructuring relief is actually used in practice.
|
||||
const stressed = classifyDays(overdueDays, isLossAsset);
|
||||
const isRestructured =
|
||||
rng() < (stressed === 'STANDARD' || stressed === 'SMA0' || stressed === 'SMA1' ? 0.04 : 0.18);
|
||||
|
||||
const sanctionedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
|
||||
// The loan must be old enough to have actually accrued `overdueDays` of delinquency, plus a
|
||||
// minimum "good" performing stretch beforehand — otherwise a badly-overdue account could end
|
||||
// up with a disbursement date that falls AFTER its own last-payment/NPA date, which would look
|
||||
// wrong no matter what day this report is viewed on.
|
||||
const minGoodPeriodDays = round(between(30, 180));
|
||||
const loanAgeDays = Math.max(round(between(0.5, 8) * 365), overdueDays + minGoodPeriodDays);
|
||||
const disbursementDate = fmt(addDays(today, -loanAgeDays));
|
||||
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
|
||||
const outstandingBalance = round(sanctionedAmount * between(0.25, 0.9));
|
||||
const overdueFrac = overdueDays === 0 ? 0 : Math.min(0.85, overdueDays / 1200 + between(0, 0.15));
|
||||
const overdueAmount = round(outstandingBalance * overdueFrac);
|
||||
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
|
||||
|
||||
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
|
||||
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
|
||||
const legalActionStatus = legalActionFor(stressed);
|
||||
|
||||
// Quarter-ago overdue count is capped at how old the loan actually was 90 days back — a loan
|
||||
// can't have been overdue for longer than it had existed at that point in time.
|
||||
const drift = round((rng() - 0.4) * 130);
|
||||
const maxQuarterAgoOverdueDays = Math.max(0, loanAgeDays - 90);
|
||||
const overdueDaysQuarterAgo = Math.min(Math.max(0, overdueDays - drift), maxQuarterAgoOverdueDays);
|
||||
|
||||
const recoveries: RecoveryTxn[] = [];
|
||||
if (overdueDays > 90 && rng() < 0.5) {
|
||||
const numRecoveries = 1 + Math.floor(rng() * 3);
|
||||
for (let r = 0; r < numRecoveries; r++) {
|
||||
recoveries.push({
|
||||
date: fmt(addDays(today, -round(between(10, Math.min(overdueDays, 400))))),
|
||||
amount: round(outstandingBalance * between(0.02, 0.12)),
|
||||
mode: pick([
|
||||
'Cash Deposit',
|
||||
'Cheque',
|
||||
'NEFT/RTGS',
|
||||
'Auction / SARFAESI',
|
||||
'One-Time Settlement'
|
||||
])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded recovery IS a payment — if one landed more recently than the formula-based
|
||||
// last-payment estimate, the account's "last payment" must reflect that, otherwise the two
|
||||
// fields on the Account Detail screen would visibly contradict each other.
|
||||
const formulaLastPaymentDate = fmt(addDays(today, -Math.max(5, overdueDays - round(between(0, 20)))));
|
||||
const latestRecoveryDate = recoveries.reduce<Date | null>((latest, r) => {
|
||||
const d = parseDMY(r.date);
|
||||
return !latest || d > latest ? d : latest;
|
||||
}, null);
|
||||
const lastPaymentDate =
|
||||
latestRecoveryDate && latestRecoveryDate > parseDMY(formulaLastPaymentDate)
|
||||
? fmt(latestRecoveryDate)
|
||||
: formulaLastPaymentDate;
|
||||
|
||||
const seq = String(1000 + i).padStart(6, '0');
|
||||
accounts.push({
|
||||
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
|
||||
customerName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
||||
guarantorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
||||
branchCode: branch.code,
|
||||
branchName: branch.name,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
secured: product.secured,
|
||||
sanctionedAmount,
|
||||
disbursementDate,
|
||||
interestRate,
|
||||
outstandingBalance,
|
||||
overdueAmount,
|
||||
overdueDays,
|
||||
overdueDaysQuarterAgo,
|
||||
collateralValue,
|
||||
lastPaymentDate,
|
||||
isLossAsset,
|
||||
isRestructured,
|
||||
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
||||
recoveries,
|
||||
npaReason,
|
||||
recoveryOfficer,
|
||||
legalActionStatus
|
||||
});
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
// 8 branches x ~80 accounts/branch on average — sized to look like a real cooperative bank's
|
||||
// loan book rather than a token demo sample. Pagination in the reports UI is what makes a book
|
||||
// this size usable, not a smaller dataset.
|
||||
export const LOAN_ACCOUNTS: LoanAccount[] = generateAccounts(640);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Classification & provisioning (RBI IRAC norms, simplified for a demo book)
|
||||
// -------------------------------------------------------------------------
|
||||
export function classifyDays(days: number, isLossAsset: boolean): NpaStage {
|
||||
if (isLossAsset && days > 1095) {
|
||||
return 'LOSS';
|
||||
}
|
||||
if (days <= 0) {
|
||||
return 'STANDARD';
|
||||
}
|
||||
if (days <= 30) {
|
||||
return 'SMA0';
|
||||
}
|
||||
if (days <= 60) {
|
||||
return 'SMA1';
|
||||
}
|
||||
if (days <= 90) {
|
||||
return 'SMA2';
|
||||
}
|
||||
if (days <= 365) {
|
||||
return 'SUBSTANDARD';
|
||||
}
|
||||
if (days <= 730) {
|
||||
return 'DOUBTFUL1';
|
||||
}
|
||||
if (days <= 1095) {
|
||||
return 'DOUBTFUL2';
|
||||
}
|
||||
return 'DOUBTFUL3';
|
||||
}
|
||||
|
||||
export function currentStage(a: LoanAccount): NpaStage {
|
||||
return classifyDays(a.overdueDays, a.isLossAsset);
|
||||
}
|
||||
|
||||
export function previousStage(a: LoanAccount): NpaStage {
|
||||
return classifyDays(a.overdueDaysQuarterAgo, a.isLossAsset && a.overdueDaysQuarterAgo > 1095);
|
||||
}
|
||||
|
||||
export function provisionRatePct(stage: NpaStage, secured: boolean): number {
|
||||
switch (stage) {
|
||||
case 'STANDARD':
|
||||
case 'SMA0':
|
||||
case 'SMA1':
|
||||
return 0.4;
|
||||
case 'SMA2':
|
||||
return 0.75;
|
||||
case 'SUBSTANDARD':
|
||||
return secured ? 15 : 25;
|
||||
case 'DOUBTFUL1':
|
||||
return secured ? 25 : 100;
|
||||
case 'DOUBTFUL2':
|
||||
return secured ? 40 : 100;
|
||||
case 'DOUBTFUL3':
|
||||
case 'LOSS':
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
export function provisionRequired(a: LoanAccount): number {
|
||||
const stage = currentStage(a);
|
||||
if (!isNpaStage(stage)) {
|
||||
return round(a.outstandingBalance * (provisionRatePct(stage, a.secured) / 100));
|
||||
}
|
||||
const securedPortion = Math.min(a.outstandingBalance, a.collateralValue);
|
||||
const unsecuredPortion = Math.max(0, a.outstandingBalance - a.collateralValue);
|
||||
const securedRate = provisionRatePct(stage, true) / 100;
|
||||
const unsecuredRate = provisionRatePct(stage, false) / 100;
|
||||
return round(securedPortion * securedRate + unsecuredPortion * unsecuredRate);
|
||||
}
|
||||
|
||||
/** Provision held can run a little above or below what's required (over/under-provisioning
|
||||
* happens in real banks) — but it can never exceed the account's own outstanding balance, since
|
||||
* a bank cannot hold more provision than the money actually at risk on that one loan. */
|
||||
export function provisionHeld(a: LoanAccount): number {
|
||||
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
|
||||
}
|
||||
|
||||
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
|
||||
export function interestReversed(a: LoanAccount): number {
|
||||
const stage = currentStage(a);
|
||||
if (!isNpaStage(stage)) {
|
||||
return 0;
|
||||
}
|
||||
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
|
||||
return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365));
|
||||
}
|
||||
|
||||
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
|
||||
* never stored separately, so it always agrees with the account's current classification. */
|
||||
export function npaSinceDate(a: LoanAccount): string {
|
||||
if (!isNpaStage(currentStage(a))) {
|
||||
return '—';
|
||||
}
|
||||
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
|
||||
return fmt(addDays(new Date(), -daysSinceNpa));
|
||||
}
|
||||
|
||||
export function totalRecovered(a: LoanAccount): number {
|
||||
return a.recoveries.reduce((s, r) => s + r.amount, 0);
|
||||
}
|
||||
|
||||
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
||||
if (prev === curr) {
|
||||
return 'No Change';
|
||||
}
|
||||
const prevIdx = STAGE_ORDER.indexOf(prev);
|
||||
const currIdx = STAGE_ORDER.indexOf(curr);
|
||||
if (currIdx > prevIdx) {
|
||||
return !isNpaStage(prev) && isNpaStage(curr) ? 'Fresh Slippage to NPA' : 'Downgraded';
|
||||
}
|
||||
return isNpaStage(prev) && !isNpaStage(curr) ? 'Upgraded to Standard' : 'Improved';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report menu metadata — drives both the shell mega-menu routerLinks and the
|
||||
// in-page group/report tab rows, so a menu item and its screen can never
|
||||
// point at different labels.
|
||||
// -------------------------------------------------------------------------
|
||||
export type ReportGroupId = 'reports' | 'analysis' | 'classification';
|
||||
export interface ReportMeta {
|
||||
id: string;
|
||||
group: ReportGroupId;
|
||||
label: string;
|
||||
}
|
||||
export const REPORT_META: ReportMeta[] = [
|
||||
{ id: 'interest', group: 'reports', label: 'NPA Interest' },
|
||||
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
|
||||
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
|
||||
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
|
||||
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
|
||||
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
|
||||
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
|
||||
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
||||
{ id: 'sma', group: 'classification', label: 'SMA Report' }
|
||||
];
|
||||
export const GROUP_LABELS: Record<ReportGroupId, string> = {
|
||||
reports: 'NPA Reports',
|
||||
analysis: 'NPA Analysis',
|
||||
classification: 'NPA Classification'
|
||||
};
|
||||
export const DEFAULT_REPORT_ID = 'summary';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { NpaReportsComponent } from './npa-reports/npa-reports.component';
|
||||
import { DEFAULT_REPORT_ID } from './npa-data';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'npa',
|
||||
data: { title: 'NPA', breadcrumb: 'NPA' },
|
||||
children: [
|
||||
{
|
||||
path: 'reports',
|
||||
redirectTo: `reports/${DEFAULT_REPORT_ID}`,
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'reports/:reportId',
|
||||
component: NpaReportsComponent,
|
||||
data: { title: 'NPA Reports', breadcrumb: 'NPA Reports' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class NpaRoutingModule {}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// NPA-specific status colouring layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*)
|
||||
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
|
||||
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
|
||||
|
||||
.npa-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--standard {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--sma0 {
|
||||
background: #fefce8;
|
||||
color: #854d0e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--sma1 {
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
&--sma2 {
|
||||
background: #fff1f0;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecdd3;
|
||||
}
|
||||
|
||||
&--substandard {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--doubtful1 {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
&--doubtful2 {
|
||||
background: #fdf2f8;
|
||||
color: #9d174d;
|
||||
border: 1px solid #fbcfe8;
|
||||
}
|
||||
|
||||
&--doubtful3 {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
border: 1px solid #ddd6fe;
|
||||
}
|
||||
|
||||
&--loss {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.npa-kpis--ratio {
|
||||
.inv-kpi__value {
|
||||
&.npa-warn {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.npa-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.npa-detail-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
dt {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps tables with more columns than comfortably fit — .pg-card clips overflow with
|
||||
// border-radius, so the scroll container has to live one level in, around the <table> only.
|
||||
.pg-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
// Lets a single field span the whole .inv-form-grid row instead of being squeezed into one
|
||||
// ~220px column — needed for the Account Detail search box, whose "code — name (branch)"
|
||||
// option text is long enough to overflow a narrow column even with ellipsis truncation.
|
||||
.npa-form-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pg-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__page {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -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 { NpaRoutingModule } from './npa-routing.module';
|
||||
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
NpaRoutingModule,
|
||||
NpaReportsComponent
|
||||
]
|
||||
})
|
||||
export class NpaModule {}
|
||||
Reference in New Issue
Block a user