diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f27549c9a..7ec73cbd5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 064459f79..30f3faa1a 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1232,39 +1232,41 @@
NPA Reports
- - - -
NPA Analysis
- - -
NPA Classification
- - +
diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 68a0f9355..79000306a 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -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; diff --git a/src/app/investment/investment-shared.scss b/src/app/investment/investment-shared.scss index 57a554a2e..66f4f2f55 100644 --- a/src/app/investment/investment-shared.scss +++ b/src/app/investment/investment-shared.scss @@ -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; diff --git a/src/app/npa/npa-data.ts b/src/app/npa/npa-data.ts new file mode 100644 index 000000000..f3e191569 --- /dev/null +++ b/src/app/npa/npa-data.ts @@ -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 = { + 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 = { + 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 = (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((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 = { + reports: 'NPA Reports', + analysis: 'NPA Analysis', + classification: 'NPA Classification' +}; +export const DEFAULT_REPORT_ID = 'summary'; diff --git a/src/app/npa/npa-reports/npa-reports.component.html b/src/app/npa/npa-reports/npa-reports.component.html new file mode 100644 index 000000000..0c75cb956 --- /dev/null +++ b/src/app/npa/npa-reports/npa-reports.component.html @@ -0,0 +1,949 @@ + + +
+
+
+
CREDIT · NON-PERFORMING ASSETS · {{ scopeName | uppercase }}
+

{{ reportLabel(activeReport) }}

+

+ 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 }}. +

+
+
+ @if (isHeadOffice) { + + } @else { + + } + + +
+
+ +
+
+ Total Advances + ₹{{ totalAdvances | number: '1.0-0' }} + {{ allAccounts.length }} accounts +
+
+ Gross NPA (GNPA) + ₹{{ gnpaAmount | number: '1.0-0' }} + {{ npaAccounts.length }} accounts · {{ gnpaRatio | number: '1.2-2' }}% of advances +
+
+ Net NPA (NNPA) + ₹{{ netNpaAmount | number: '1.0-0' }} + {{ nnpaRatio | number: '1.2-2' }}% of advances +
+
+ Provision Held (NPA) + ₹{{ totalNpaProvisionHeld | number: '1.0-0' }} + Recovered to date ₹{{ totalRecoveredAmount | number: '1.0-0' }} +
+
+ Restructured Accounts + {{ restructuredAccounts.length }} + ₹{{ restructuredOutstanding | number: '1.0-0' }} outstanding +
+
+ +
+ @for (g of ['reports', 'analysis', 'classification']; track g) { + + } +
+ +
+ @for (m of groupOf(activeGroup); track m.id) { + + } +
+ + + @if (activeReport === 'interest') { +
+ + + + + + + + + + + + + + + + + @for (a of paged(npaAccounts); track a.accountNo) { + + + + + + + + + + + + } @empty { + + + + } + + + + + + + +
Account NoCustomerBranchProductStageOutstandingRateOverdue DaysInterest Reversed
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }} + {{ stageLabel(stage(a)) }} + ₹{{ a.outstandingBalance | number: '1.0-0' }}{{ a.interestRate }}%{{ a.overdueDays }}₹{{ interestRev(a) | number: '1.0-0' }}
No NPA accounts in this branch.
Total Interest Reversed ({{ npaAccounts.length }} accounts)₹{{ sumInterest(npaAccounts) | number: '1.0-0' }}
+ @if (npaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(npaAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'status') { +
+ +
+ + + + + + + + + + + + + + + + + + + + @for (a of paged(npaAccounts); track a.accountNo) { + + + + + + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchProductSecuredOutstandingOverdue AmountOverdue DaysNPA SinceStageRestructuredLegal ActionProvision Required
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }}{{ a.secured ? 'Yes' : 'No' }}₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.overdueAmount | number: '1.0-0' }}{{ a.overdueDays }}{{ npaSince(a) }} + {{ stageLabel(stage(a)) }} + {{ a.isRestructured ? 'Yes' : 'No' }}{{ legalActionLabel(a.legalActionStatus) }}₹{{ provisionReq(a) | number: '1.0-0' }}
No NPA accounts in this branch.
+
+ @if (npaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(npaAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'recovery') { +
+ +
+ + + + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + @for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) { + + + + + + + + + } @empty { + + + + } + + + + + + + +
DateAccount NoCustomerBranchModeRecovered Amount
{{ r.date }} + {{ r.accountNo }} + {{ r.customerName }}{{ r.branchName }}{{ r.mode }}₹{{ r.amount | number: '1.0-0' }}
No recoveries recorded yet.
Total Recovered₹{{ totalRecoveredAmount | number: '1.0-0' }}
+ @if (recoveryRows.length > pageSize) { +
+ Showing {{ rangeStart(recoveryRows) }}–{{ rangeEnd(recoveryRows) }} of {{ recoveryRows.length }} +
+ + Page {{ currentPage }} of {{ pageCount(recoveryRows) }} + +
+
+ } +
+ } + + + @if (activeReport === 'summary') { +
+ + + + + + + + + + + + @for (row of stageSummary; track row.stage) { + + + + + + + } + + + + + + + + + +
ClassificationAccountsOutstandingProvision Required
+ {{ row.label }} + {{ row.count }}₹{{ row.outstanding | number: '1.0-0' }}₹{{ row.provisionReq | number: '1.0-0' }}
Total{{ grandTotalAccounts }}₹{{ grandTotalOutstanding | number: '1.0-0' }}₹{{ grandTotalProvision | number: '1.0-0' }}
+
+ + @if (isHeadOffice && !branchFilter) { +
+ + + + + + + + + + + + + + + @for (row of branchSummary; track row.branchCode) { + + + + + + + + + + } + +
BranchTotal AccountsTotal AdvancesNPA AccountsGNPA OutstandingProvision RequiredGNPA %
+ {{ row.branchName }} + {{ row.totalAccounts }}₹{{ row.totalAdvances | number: '1.0-0' }}{{ row.npaCount }}₹{{ row.npaOutstanding | number: '1.0-0' }}₹{{ row.provisionReq | number: '1.0-0' }}{{ row.gnpaRatio | number: '1.2-2' }}%
+
+ } + } + + + @if (activeReport === 'account-detail') { +
+ +
+ +
+
+ +
+
+ + @if (selectedAccount; as a) { +
+ +
+
+
Branch
+
{{ a.branchName }}
+
+
+
Product
+
{{ a.productName }}
+
+
+
Secured
+
{{ a.secured ? 'Yes' : 'No' }}
+
+
+
Guarantor
+
{{ a.guarantorName }}
+
+
+
Sanctioned Amount
+
₹{{ a.sanctionedAmount | number: '1.0-0' }}
+
+
+
Disbursement Date
+
{{ a.disbursementDate }}
+
+
+
Interest Rate
+
{{ a.interestRate }}%
+
+
+
Outstanding Balance
+
₹{{ a.outstandingBalance | number: '1.0-0' }}
+
+
+
Overdue Amount
+
₹{{ a.overdueAmount | number: '1.0-0' }}
+
+
+
Overdue Days
+
{{ a.overdueDays }}
+
+
+
NPA Since
+
{{ npaSince(a) }}
+
+
+
Restructured
+
{{ a.isRestructured ? 'Yes' : 'No' }}
+
+
+
Last Payment Date
+
{{ a.lastPaymentDate }}
+
+
+
Collateral Value
+
₹{{ a.collateralValue | number: '1.0-0' }}
+
+
+
Provisioning Rate
+
{{ provisionRate(a) }}%
+
+
+
Provision Required
+
₹{{ provisionReq(a) | number: '1.0-0' }}
+
+
+
Provision Held
+
₹{{ provisionHeldAmt(a) | number: '1.0-0' }}
+
+
+
Interest Reversed
+
₹{{ interestRev(a) | number: '1.0-0' }}
+
+
+
Total Recovered
+
₹{{ recovered(a) | number: '1.0-0' }}
+
+
+
Movement (vs Last Quarter)
+
{{ movement(a) }}
+
+
+
NPA Reason
+
{{ a.npaReason }}
+
+
+
Recovery Officer
+
{{ a.recoveryOfficer }}
+
+
+
Legal Action Status
+
{{ legalActionLabel(a.legalActionStatus) }}
+
+
+
+ + @if (isNpaStageOf(a)) { +
+ +
+ + + + + +
+
+ } + +
+ + + + + + + + + + + @for (r of a.recoveries; track r.date + r.amount) { + + + + + + } @empty { + + + + } + +
DateModeAmount
{{ r.date }}{{ r.mode }}₹{{ r.amount | number: '1.0-0' }}
No recoveries recorded against this account.
+
+ } + } + + + @if (activeReport === 'movement') { +
+ + + + + + + + + + + + + + + @for (a of paged(movedAccounts); track a.accountNo) { + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchPrevious StageCurrent StageMovementOutstanding
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }} + {{ stageLabel(prevStage(a)) }} + + {{ stageLabel(stage(a)) }} + {{ movement(a) }}₹{{ a.outstandingBalance | number: '1.0-0' }}
No classification movement in this branch.
+ @if (movedAccounts.length > pageSize) { +
+ Showing {{ rangeStart(movedAccounts) }}–{{ rangeEnd(movedAccounts) }} of {{ movedAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(movedAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'provision') { +
+ + + + + + + + + + + + + + + + + @for (a of paged(provisionRows); track a.accountNo) { + + + + + + + + + + + + } + + + + + + + + + +
Account NoCustomerBranchStageOutstandingRateProvision RequiredProvision HeldShortfall / (Excess)
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }} + {{ stageLabel(stage(a)) }} + ₹{{ a.outstandingBalance | number: '1.0-0' }}{{ provisionRate(a) }}%₹{{ provisionReq(a) | number: '1.0-0' }}₹{{ provisionHeldAmt(a) | number: '1.0-0' }} + ₹{{ shortfall(a) | number: '1.0-0' }} +
Total ({{ provisionRows.length }} accounts)₹{{ provisionRequiredTotal | number: '1.0-0' }}₹{{ provisionHeldTotal | number: '1.0-0' }}₹{{ provisionShortfallTotal | number: '1.0-0' }}
+ @if (provisionRows.length > pageSize) { +
+ Showing {{ rangeStart(provisionRows) }}–{{ rangeEnd(provisionRows) }} of {{ provisionRows.length }} +
+ + Page {{ currentPage }} of {{ pageCount(provisionRows) }} + +
+
+ } +
+ } + + + @if (activeReport === 'probable') { +
+ + + + + + + + + + + + + + + + + @for (a of paged(probableNpaAccounts); track a.accountNo) { + + + + + + + + + + + + } @empty { + + + + } + + + + + + + + +
Account NoCustomerBranchProductOutstandingOverdue AmountOverdue DaysLast PaymentFollow-up
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }}₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.overdueAmount | number: '1.0-0' }}{{ a.overdueDays }}{{ a.lastPaymentDate }} + + {{ a.overdueDays > 75 ? 'Urgent' : 'Monitor' }} + +
No SMA-2 accounts in this branch.
Total ({{ probableNpaAccounts.length }} accounts)₹{{ sumOutstanding(probableNpaAccounts) | number: '1.0-0' }}
+ @if (probableNpaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(probableNpaAccounts) }}–{{ rangeEnd(probableNpaAccounts) }} of + {{ probableNpaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(probableNpaAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'sma') { +
+ + + + + + + + + + + + + + + + @for (a of paged(smaAccounts); track a.accountNo) { + + + + + + + + + + + } @empty { + + + + } + + + + + + + + + +
Account NoCustomerBranchProductStageOutstandingOverdue AmountOverdue Days
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }} + {{ stageLabel(stage(a)) }} + ₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.overdueAmount | number: '1.0-0' }}{{ a.overdueDays }}
No SMA accounts in this branch.
Total ({{ smaAccounts.length }} accounts)₹{{ sumOutstanding(smaAccounts) | number: '1.0-0' }}₹{{ sumOverdue(smaAccounts) | number: '1.0-0' }}
+ @if (smaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(smaAccounts) }}–{{ rangeEnd(smaAccounts) }} of {{ smaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(smaAccounts) }} + +
+
+ } +
+ } +
+ +
{{ toastMsg }}
diff --git a/src/app/npa/npa-reports/npa-reports.component.ts b/src/app/npa/npa-reports/npa-reports.component.ts new file mode 100644 index 000000000..5d6eb24e5 --- /dev/null +++ b/src/app/npa/npa-reports/npa-reports.component.ts @@ -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 | undefined; + private sub?: Subscription; + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly auth = inject(AuthenticationService); + + ngOnInit(): void { + this.sub = this.route.paramMap.subscribe((params) => { + const id = params.get('reportId') || DEFAULT_REPORT_ID; + const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[3]; + this.activeReport = meta.id; + this.activeGroup = meta.group; + this.currentPage = 1; + }); + + const creds = this.auth.getCredentials(); + this.isHeadOffice = this.auth.isHeadOfficeUser(); + this.currentOfficeName = creds?.officeName || 'Branch Office'; + if (this.isHeadOffice) { + // Head Office starts on the consolidated view and can drill into any branch. + this.branchFilter = ''; + } else { + // Match the logged-in office's real name to one of the demo branches, so each branch's + // login sees only its own book. Falls back to the first demo branch (still labelled with + // the user's real office name) if no branch name matches. + this.ownBranchCode = + this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null; + this.branchFilter = this.ownBranchCode || this.branches[0].code; + } + + const firstNpa = this.npaAccounts[0]; + this.selectedAccountNo = firstNpa ? firstNpa.accountNo : this.allAccounts[0]?.accountNo || ''; + } + + ngOnDestroy(): void { + this.sub?.unsubscribe(); + } + + /** Name of the branch/scope currently being viewed, for display in the header. */ + get scopeName(): string { + if (!this.isHeadOffice) { + return this.currentOfficeName; + } + return this.branchFilter + ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' + : 'All Branches (Consolidated)'; + } + + setBranch(code: string): void { + this.branchFilter = code; + this.currentPage = 1; + } + + // -------------------- Pagination (shared by every report's table) -------------------- + paged(items: T[]): T[] { + const start = (this.currentPage - 1) * this.pageSize; + return items.slice(start, start + this.pageSize); + } + + pageCount(items: unknown[]): number { + return Math.max(1, Math.ceil(items.length / this.pageSize)); + } + + rangeStart(items: unknown[]): number { + return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1; + } + + rangeEnd(items: unknown[]): number { + return Math.min(this.currentPage * this.pageSize, items.length); + } + + goToPage(page: number, items: unknown[]): void { + this.currentPage = Math.min(Math.max(1, page), this.pageCount(items)); + } + + goToReport(reportId: string): void { + this.router.navigate([ + '/npa/reports', + reportId + ]); + } + + groupOf(group: string) { + return this.reportMeta.filter((m) => m.group === group); + } + + reportLabel(id: string): string { + return this.reportMeta.find((m) => m.id === id)?.label || id; + } + + stage(a: LoanAccount): NpaStage { + return currentStage(a); + } + + prevStage(a: LoanAccount): NpaStage { + return previousStage(a); + } + + stageLabel(stage: NpaStage): string { + return STAGE_LABELS[stage]; + } + + badgeClass(stage: NpaStage): string { + return stageBadgeClass(stage); + } + + provisionRate(a: LoanAccount): number { + return provisionRatePct(this.stage(a), a.secured); + } + + provisionReq(a: LoanAccount): number { + return provisionRequired(a); + } + + provisionHeldAmt(a: LoanAccount): number { + return provisionHeld(a); + } + + interestRev(a: LoanAccount): number { + return interestReversed(a); + } + + recovered(a: LoanAccount): number { + return totalRecovered(a); + } + + movement(a: LoanAccount): string { + return movementLabel(this.prevStage(a), this.stage(a)); + } + + npaSince(a: LoanAccount): string { + return npaSinceDate(a); + } + + 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); + } +} diff --git a/src/app/npa/npa-routing.module.ts b/src/app/npa/npa-routing.module.ts new file mode 100644 index 000000000..02866ba8e --- /dev/null +++ b/src/app/npa/npa-routing.module.ts @@ -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 {} diff --git a/src/app/npa/npa-shared.scss b/src/app/npa/npa-shared.scss new file mode 100644 index 000000000..64e3d6cfe --- /dev/null +++ b/src/app/npa/npa-shared.scss @@ -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 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; + } +} diff --git a/src/app/npa/npa.module.ts b/src/app/npa/npa.module.ts new file mode 100644 index 000000000..359ec737a --- /dev/null +++ b/src/app/npa/npa.module.ts @@ -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 {}