feat(npa): add Recovery Management System reports + Recovery Journey popup
Adds loan-type-aware recovery tracking (EMI/Overdrawn/Bullet) with a Recovery Management Dashboard, Day-wise Overdue/Overdrawn reports, an Agriculture/Gold market-value report, and a security-value exception check — all derived from new repaymentType/emiAmount/drawingPower/maturityDate/collateralMarketValue fields on LoanAccount, wired into Export to Excel and the NPA mega-menu. Also replaces the always-visible Recovery/Collection Journey card on the Account Detail Report with a "Recovery Journey" popup: a date-led, icon-coded timeline of every SMS/call/visit/notice and payment run against an account.
This commit is contained in:
@@ -1278,6 +1278,25 @@
|
||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recovery Management -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Recovery Management</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
|
||||
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
|
||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
|
||||
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
|
||||
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
|
||||
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
|
||||
@@ -749,6 +749,13 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
|
||||
.npa-grid {
|
||||
grid-template-columns: repeat(4, minmax(200px, 1fr));
|
||||
min-width: 880px;
|
||||
max-width: 96vw;
|
||||
}
|
||||
|
||||
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
|
||||
.reconciliation-grid {
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
||||
|
||||
+349
-13
@@ -79,6 +79,26 @@ export interface RecoveryTxn {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/** One entry in an account's collection/recovery journey — the real-world touchpoints a CBS
|
||||
* Recovery Management System logs against an overdue loan, in the order they actually happened. */
|
||||
export type RecoveryEventType =
|
||||
| 'SMS Reminder'
|
||||
| 'IVR / Auto Call'
|
||||
| 'Tele-calling'
|
||||
| 'Overdue Call'
|
||||
| 'Field Visit'
|
||||
| 'Formal Overdue Letter'
|
||||
| 'Recovery Agent Assigned'
|
||||
| 'Demand Notice'
|
||||
| 'Payment Received';
|
||||
|
||||
export interface RecoveryEvent {
|
||||
date: string;
|
||||
type: RecoveryEventType;
|
||||
channel: string;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export interface LoanAccount {
|
||||
accountNo: string;
|
||||
customerName: string;
|
||||
@@ -88,6 +108,7 @@ export interface LoanAccount {
|
||||
productCode: string;
|
||||
productName: string;
|
||||
secured: boolean;
|
||||
repaymentType: RepaymentType;
|
||||
sanctionedAmount: number;
|
||||
disbursementDate: string;
|
||||
interestRate: number;
|
||||
@@ -96,11 +117,27 @@ export interface LoanAccount {
|
||||
overdueDays: number;
|
||||
overdueDaysQuarterAgo: number;
|
||||
collateralValue: number;
|
||||
/** Current estimated market value of the pledged security — can run above or below
|
||||
* `collateralValue` (the value recorded at sanction/last valuation), which is exactly what the
|
||||
* Exception Check / Agriculture-Gold Market Value reports exist to catch. 0 for unsecured loans. */
|
||||
collateralMarketValue: number;
|
||||
/** Fixed EMI for an EMI-repayment loan (standard reducing-balance formula on the sanctioned
|
||||
* amount and tenure) — 0 for OVERDRAWN/BULLET loans, which aren't due on a fixed instalment. */
|
||||
emiAmount: number;
|
||||
/** Drawing limit for an OVERDRAWN (CC/OD/KCC) account — kept below the sanctioned amount by a
|
||||
* margin, same as a real cash-credit facility. 0 for EMI/BULLET loans. */
|
||||
drawingPower: number;
|
||||
/** Bullet-repayment due date (principal falls due in full on this date) — '—' for EMI/OVERDRAWN
|
||||
* loans, which don't have a single bullet maturity. */
|
||||
maturityDate: string;
|
||||
lastPaymentDate: string;
|
||||
isLossAsset: boolean;
|
||||
isRestructured: boolean;
|
||||
provisionCoverageRatio: number;
|
||||
recoveries: RecoveryTxn[];
|
||||
/** Chronological log of collection touchpoints (SMS/call/visit/notice) plus actual recoveries —
|
||||
* empty for an account that has never gone overdue. */
|
||||
collectionTimeline: RecoveryEvent[];
|
||||
/** '—' 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. */
|
||||
@@ -174,6 +211,17 @@ export const BRANCHES: BranchMaster[] = [
|
||||
{ code: 'BR08', name: 'Kolhapur Branch' }
|
||||
];
|
||||
|
||||
/** How the loan is contractually repaid — drives the Recovery Management System's per-type
|
||||
* overdue check (EMI shortfall / drawing-power breach / bullet-maturity lapse) and the
|
||||
* Expected-vs-Actual Recovery figures, since each type is "due" on a different basis. */
|
||||
export type RepaymentType = 'EMI' | 'OVERDRAWN' | 'BULLET';
|
||||
|
||||
export const REPAYMENT_TYPE_LABELS: Record<RepaymentType, string> = {
|
||||
EMI: 'Term Loan (EMI)',
|
||||
OVERDRAWN: 'Overdrawn (CC / OD)',
|
||||
BULLET: 'Bullet Repayment'
|
||||
};
|
||||
|
||||
interface ProductMaster {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -182,6 +230,11 @@ interface ProductMaster {
|
||||
maxAmt: number;
|
||||
minRate: number;
|
||||
maxRate: number;
|
||||
repaymentType: RepaymentType;
|
||||
/** Sanctioned tenure in months — drives the EMI formula for EMI-type products and the
|
||||
* bullet maturity date for BULLET-type products. Renewal cycle (not a repayment schedule)
|
||||
* for OVERDRAWN/revolving products. */
|
||||
tenureMonths: number;
|
||||
}
|
||||
export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||
{
|
||||
@@ -191,15 +244,87 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||
minAmt: 500000,
|
||||
maxAmt: 5000000,
|
||||
minRate: 11,
|
||||
maxRate: 13.5
|
||||
maxRate: 13.5,
|
||||
repaymentType: 'OVERDRAWN',
|
||||
tenureMonths: 12
|
||||
},
|
||||
{
|
||||
code: '02',
|
||||
name: 'Term Loan - MSME',
|
||||
secured: true,
|
||||
minAmt: 300000,
|
||||
maxAmt: 3000000,
|
||||
minRate: 11,
|
||||
maxRate: 14,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 60
|
||||
},
|
||||
{
|
||||
code: '03',
|
||||
name: 'Housing Loan',
|
||||
secured: true,
|
||||
minAmt: 1000000,
|
||||
maxAmt: 4000000,
|
||||
minRate: 8.5,
|
||||
maxRate: 10.5,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 180
|
||||
},
|
||||
{
|
||||
code: '04',
|
||||
name: 'Vehicle Loan',
|
||||
secured: true,
|
||||
minAmt: 300000,
|
||||
maxAmt: 1500000,
|
||||
minRate: 9,
|
||||
maxRate: 11.5,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 84
|
||||
},
|
||||
{
|
||||
code: '05',
|
||||
name: 'Gold Loan',
|
||||
secured: true,
|
||||
minAmt: 50000,
|
||||
maxAmt: 1000000,
|
||||
minRate: 9,
|
||||
maxRate: 12,
|
||||
repaymentType: 'BULLET',
|
||||
tenureMonths: 12
|
||||
},
|
||||
{
|
||||
code: '06',
|
||||
name: 'Kisan Credit Card',
|
||||
secured: true,
|
||||
minAmt: 100000,
|
||||
maxAmt: 500000,
|
||||
minRate: 7,
|
||||
maxRate: 9,
|
||||
repaymentType: 'OVERDRAWN',
|
||||
tenureMonths: 12
|
||||
},
|
||||
{
|
||||
code: '07',
|
||||
name: 'Personal Loan',
|
||||
secured: false,
|
||||
minAmt: 50000,
|
||||
maxAmt: 500000,
|
||||
minRate: 13,
|
||||
maxRate: 16,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 36
|
||||
},
|
||||
{
|
||||
code: '08',
|
||||
name: 'Education Loan',
|
||||
secured: false,
|
||||
minAmt: 200000,
|
||||
maxAmt: 1500000,
|
||||
minRate: 10,
|
||||
maxRate: 12,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 84
|
||||
},
|
||||
{ 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',
|
||||
@@ -207,7 +332,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||
minAmt: 20000,
|
||||
maxAmt: 200000,
|
||||
minRate: 12,
|
||||
maxRate: 14
|
||||
maxRate: 14,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 24
|
||||
},
|
||||
{
|
||||
code: '10',
|
||||
@@ -216,7 +343,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||
minAmt: 500000,
|
||||
maxAmt: 2500000,
|
||||
minRate: 11,
|
||||
maxRate: 13
|
||||
maxRate: 13,
|
||||
repaymentType: 'OVERDRAWN',
|
||||
tenureMonths: 12
|
||||
}
|
||||
];
|
||||
|
||||
@@ -345,6 +474,113 @@ function pickBucket(): StageBucket {
|
||||
return BUCKETS[0];
|
||||
}
|
||||
|
||||
/** Standard reducing-balance EMI formula on the original sanctioned amount and tenure — the same
|
||||
* fixed instalment the loan was actually sanctioned with, not something recomputed off the
|
||||
* current (partly-repaid) outstanding balance. */
|
||||
function calcEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
|
||||
const r = annualRatePct / 12 / 100;
|
||||
if (r === 0) {
|
||||
return round(principal / tenureMonths);
|
||||
}
|
||||
const factor = Math.pow(1 + r, tenureMonths);
|
||||
return round((principal * r * factor) / (factor - 1));
|
||||
}
|
||||
|
||||
interface TimelineCheckpoint {
|
||||
atDay: number;
|
||||
type: RecoveryEventType;
|
||||
channel: string;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/** The real-world escalation ladder a CBS Recovery Management System runs an overdue account
|
||||
* through — each checkpoint only fires once the account has actually been overdue that long, so
|
||||
* a 10-day-overdue account shows just the SMS/call steps while a 200-day NPA shows the full
|
||||
* ladder up to the demand notice. */
|
||||
const TIMELINE_CHECKPOINTS: TimelineCheckpoint[] = [
|
||||
{
|
||||
atDay: 3,
|
||||
type: 'SMS Reminder',
|
||||
channel: 'SMS Gateway',
|
||||
remarks: 'Auto SMS reminder sent for the overdue instalment/interest.'
|
||||
},
|
||||
{
|
||||
atDay: 7,
|
||||
type: 'IVR / Auto Call',
|
||||
channel: 'IVR System',
|
||||
remarks: 'Automated voice-call reminder placed to the registered mobile number.'
|
||||
},
|
||||
{
|
||||
atDay: 15,
|
||||
type: 'Tele-calling',
|
||||
channel: 'Branch Tele-calling Desk',
|
||||
remarks: 'Branch staff called the customer to follow up on the continuing overdue.'
|
||||
},
|
||||
{
|
||||
atDay: 25,
|
||||
type: 'Overdue Call',
|
||||
channel: 'Recovery Officer',
|
||||
remarks: 'Recovery officer placed a personal follow-up call.'
|
||||
},
|
||||
{
|
||||
atDay: 30,
|
||||
type: 'Field Visit',
|
||||
channel: 'Branch Staff',
|
||||
remarks: 'Field visit conducted at the registered address — account under SMA monitoring.'
|
||||
},
|
||||
{
|
||||
atDay: 45,
|
||||
type: 'SMS Reminder',
|
||||
channel: 'SMS Gateway',
|
||||
remarks: 'Second reminder SMS sent — account still overdue.'
|
||||
},
|
||||
{
|
||||
atDay: 60,
|
||||
type: 'Formal Overdue Letter',
|
||||
channel: 'Registered Post',
|
||||
remarks: 'Formal overdue letter dispatched — account crossed into SMA-2 monitoring.'
|
||||
},
|
||||
{
|
||||
atDay: 75,
|
||||
type: 'Recovery Agent Assigned',
|
||||
channel: 'External Recovery Agency',
|
||||
remarks: 'Case assigned to a recovery agent for intensified follow-up.'
|
||||
},
|
||||
{
|
||||
atDay: 91,
|
||||
type: 'Demand Notice',
|
||||
channel: 'Recovery Officer',
|
||||
remarks: 'Account classified NPA — formal demand notice process initiated.'
|
||||
}
|
||||
];
|
||||
|
||||
/** Builds the account's full collection journey — every escalation checkpoint the account has
|
||||
* actually reached, interleaved date-wise with its real cash recoveries — so the Account Detail
|
||||
* screen can show one combined, chronological timeline instead of two disconnected lists. */
|
||||
function buildCollectionTimeline(overdueDays: number, recoveries: RecoveryTxn[], today: Date): RecoveryEvent[] {
|
||||
const events: RecoveryEvent[] = [];
|
||||
for (const cp of TIMELINE_CHECKPOINTS) {
|
||||
if (cp.atDay <= overdueDays) {
|
||||
events.push({
|
||||
date: fmt(addDays(today, -(overdueDays - cp.atDay))),
|
||||
type: cp.type,
|
||||
channel: cp.channel,
|
||||
remarks: cp.remarks
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const r of recoveries) {
|
||||
events.push({
|
||||
date: r.date,
|
||||
type: 'Payment Received',
|
||||
channel: r.mode,
|
||||
remarks: `Recovery of ₹${r.amount.toLocaleString('en-IN')} received via ${r.mode}.`
|
||||
});
|
||||
}
|
||||
events.sort((a, b) => parseDMY(a.date).getTime() - parseDMY(b.date).getTime());
|
||||
return events;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -398,6 +634,26 @@ function generateAccounts(count: number): LoanAccount[] {
|
||||
const overdueAmount = round(outstandingBalance * overdueFrac);
|
||||
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
|
||||
|
||||
// Current market value of the pledged security vs its value at sanction: gold trends upward
|
||||
// over time (real-world gold-price appreciation); crop/agri collateral (KCC) is volatile in
|
||||
// both directions, matching how "Crop Failure" shows up as an NPA reason; other secured
|
||||
// collateral (property/vehicle) moves in a narrower band. Unsecured loans have none.
|
||||
let marketValueFactor = 0;
|
||||
if (product.code === '05') {
|
||||
marketValueFactor = between(1.05, 1.35);
|
||||
} else if (product.code === '06') {
|
||||
marketValueFactor = between(0.55, 1.2);
|
||||
} else if (product.secured) {
|
||||
marketValueFactor = between(0.85, 1.15);
|
||||
}
|
||||
const collateralMarketValue = product.secured ? round(collateralValue * marketValueFactor) : 0;
|
||||
|
||||
const emiAmount =
|
||||
product.repaymentType === 'EMI' ? calcEmi(sanctionedAmount, interestRate, product.tenureMonths) : 0;
|
||||
const drawingPower = product.repaymentType === 'OVERDRAWN' ? round(sanctionedAmount * between(0.75, 0.9)) : 0;
|
||||
const maturityDate =
|
||||
product.repaymentType === 'BULLET' ? fmt(addDays(parseDMY(disbursementDate), product.tenureMonths * 30)) : '—';
|
||||
|
||||
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
|
||||
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
|
||||
const legalActionStatus = legalActionFor(stressed);
|
||||
@@ -439,6 +695,8 @@ function generateAccounts(count: number): LoanAccount[] {
|
||||
? fmt(latestRecoveryDate)
|
||||
: formulaLastPaymentDate;
|
||||
|
||||
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
|
||||
|
||||
const seq = String(1000 + i).padStart(6, '0');
|
||||
accounts.push({
|
||||
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
|
||||
@@ -449,6 +707,7 @@ function generateAccounts(count: number): LoanAccount[] {
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
secured: product.secured,
|
||||
repaymentType: product.repaymentType,
|
||||
sanctionedAmount,
|
||||
disbursementDate,
|
||||
interestRate,
|
||||
@@ -457,11 +716,16 @@ function generateAccounts(count: number): LoanAccount[] {
|
||||
overdueDays,
|
||||
overdueDaysQuarterAgo,
|
||||
collateralValue,
|
||||
collateralMarketValue,
|
||||
emiAmount,
|
||||
drawingPower,
|
||||
maturityDate,
|
||||
lastPaymentDate,
|
||||
isLossAsset,
|
||||
isRestructured,
|
||||
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
||||
recoveries,
|
||||
collectionTimeline,
|
||||
npaReason,
|
||||
recoveryOfficer,
|
||||
legalActionStatus
|
||||
@@ -602,6 +866,72 @@ export function undebitedInterestBalance(a: LoanAccount): number {
|
||||
return interestReversed(a) - interestRecoveredCash(a);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recovery Management System — per-repayment-type overdue check, Expected vs
|
||||
// Actual Recovery, and the collateral-adequacy exception check. `overdueDays`
|
||||
// stays the single universal "how overdue is this account" signal (so IRAC
|
||||
// classification never needs a second, type-specific day-counter) — only its
|
||||
// PLAIN-ENGLISH INTERPRETATION differs by repayment type: an EMI account
|
||||
// missed its instalment, an OVERDRAWN account has been over its drawing power,
|
||||
// a BULLET account is past maturity unpaid.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** True once a bullet loan's maturity date has passed — the point at which the bank checks
|
||||
* whether the account "self-closed" (fully repaid) or is now overdue. */
|
||||
export function isBulletMatured(a: LoanAccount): boolean {
|
||||
return a.repaymentType === 'BULLET' && a.maturityDate !== '—' && parseDMY(a.maturityDate).getTime() <= Date.now();
|
||||
}
|
||||
|
||||
/** A bullet loan past maturity that's still carrying an overdue balance. */
|
||||
export function isBulletOverdue(a: LoanAccount): boolean {
|
||||
return isBulletMatured(a) && a.overdueDays > 0;
|
||||
}
|
||||
|
||||
/** A cash-credit/overdraft-style account currently drawn beyond its sanctioned drawing power. */
|
||||
export function isOverdrawnBreach(a: LoanAccount): boolean {
|
||||
return a.repaymentType === 'OVERDRAWN' && a.outstandingBalance > a.drawingPower;
|
||||
}
|
||||
|
||||
/** What ought to be recovered this month, by repayment type: the fixed EMI for a term loan, one
|
||||
* month's interest-servicing for an overdrawn CC/OD/KCC account, or the full outstanding once a
|
||||
* bullet loan has matured (0 before maturity — nothing is contractually due yet). */
|
||||
export function expectedRecoveryThisMonth(a: LoanAccount): number {
|
||||
switch (a.repaymentType) {
|
||||
case 'EMI':
|
||||
return a.emiAmount;
|
||||
case 'OVERDRAWN':
|
||||
return round((a.outstandingBalance * a.interestRate) / 100 / 12);
|
||||
case 'BULLET':
|
||||
return isBulletMatured(a) ? a.outstandingBalance : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** What was actually recovered this month — `overdueDays === 0` means the account is fully
|
||||
* current, i.e. this cycle's EMI/interest/bullet-repayment landed in full; any overdue days at
|
||||
* all means it didn't, exactly the binary "full amount or mark Overdue" check a real recovery
|
||||
* desk runs (this demo book doesn't model partial-instalment part-payments). */
|
||||
export function actualRecoveryThisMonth(a: LoanAccount): number {
|
||||
if (a.repaymentType === 'BULLET') {
|
||||
return isBulletMatured(a) && a.overdueDays === 0 ? a.outstandingBalance : 0;
|
||||
}
|
||||
return a.overdueDays === 0 ? expectedRecoveryThisMonth(a) : 0;
|
||||
}
|
||||
|
||||
/** Current collateral cover — market value of security as a % of outstanding balance. 0 for an
|
||||
* unsecured loan (no collateral to speak of). */
|
||||
export function securityCoverageRatio(a: LoanAccount): number {
|
||||
if (!a.secured || a.outstandingBalance <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (a.collateralMarketValue / a.outstandingBalance) * 100;
|
||||
}
|
||||
|
||||
/** True when a secured loan's CURRENT security market value has fallen below what's actually
|
||||
* outstanding — the "security value is above loan" exception check every recovery review runs. */
|
||||
export function isUndersecured(a: LoanAccount): boolean {
|
||||
return a.secured && a.collateralMarketValue < a.outstandingBalance;
|
||||
}
|
||||
|
||||
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
||||
if (prev === curr) {
|
||||
return 'No Change';
|
||||
@@ -619,7 +949,7 @@ export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
||||
// 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 type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
|
||||
export interface ReportMeta {
|
||||
id: string;
|
||||
group: ReportGroupId;
|
||||
@@ -635,11 +965,17 @@ export const REPORT_META: ReportMeta[] = [
|
||||
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
|
||||
{ id: 'undebited-interest', group: 'analysis', label: 'Undebited Interest Recovery Report' },
|
||||
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
||||
{ id: 'sma', group: 'classification', label: 'SMA Report' }
|
||||
{ id: 'sma', group: 'classification', label: 'SMA Report' },
|
||||
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
|
||||
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
|
||||
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
|
||||
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
|
||||
{ id: 'exception-check', group: 'recovery-mgmt', label: 'Exception Check — Security Value vs Loan' }
|
||||
];
|
||||
export const GROUP_LABELS: Record<ReportGroupId, string> = {
|
||||
reports: 'NPA Reports',
|
||||
analysis: 'NPA Analysis',
|
||||
classification: 'NPA Classification'
|
||||
classification: 'NPA Classification',
|
||||
'recovery-mgmt': 'Recovery Management'
|
||||
};
|
||||
export const DEFAULT_REPORT_ID = 'summary';
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
@for (g of ['reports', 'analysis', 'classification']; track g) {
|
||||
@for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
@@ -462,9 +462,14 @@
|
||||
|
||||
@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 class="pos-section-label npa-detail-header">
|
||||
<span
|
||||
>{{ a.accountNo }} — {{ a.customerName }}
|
||||
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span></span
|
||||
>
|
||||
<button type="button" class="pg-btn npa-timeline-trigger" (click)="openTimelineModal()">
|
||||
<fa-icon icon="history"></fa-icon> Recovery Journey
|
||||
</button>
|
||||
</div>
|
||||
<dl class="npa-detail-grid">
|
||||
<div class="npa-detail-field">
|
||||
@@ -1037,6 +1042,548 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 11 RECOVERY MANAGEMENT DASHBOARD ===================== -->
|
||||
@if (activeReport === 'recovery-dashboard') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Recovery Management Dashboard — Loan-Type Overdue Check</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
|
||||
is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
|
||||
servicing, and a Bullet loan (Gold) is checked once past maturity.
|
||||
</p>
|
||||
<div class="inv-kpis npa-kpis--three">
|
||||
@for (row of recoveryTypeSummary; track row.type) {
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">{{ row.label }}</span>
|
||||
<span class="inv-kpi__value">{{ row.accounts }} accounts</span>
|
||||
<span class="inv-kpi__sub" [class.npa-warn]="row.overdueCount > 0"
|
||||
>{{ row.overdueCount }} overdue this cycle</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Expected vs Actual Recovery — This Cycle, by Loan Type</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Loan Type</th>
|
||||
<th class="num">Accounts</th>
|
||||
<th class="num">Overdue Now</th>
|
||||
<th class="num">Expected Recovery</th>
|
||||
<th class="num col-in">Actual Recovery</th>
|
||||
<th class="num">Variance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of recoveryTypeSummary; track row.type) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.accounts }}</td>
|
||||
<td class="num">{{ row.overdueCount }}</td>
|
||||
<td class="num">₹{{ row.expected | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.col-out]="row.actual - row.expected < 0">
|
||||
₹{{ row.actual - row.expected | number: '1.0-0' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>Total</td>
|
||||
<td class="num">{{ allAccounts.length }}</td>
|
||||
<td class="num">
|
||||
{{
|
||||
recoveryTypeSummary[0].overdueCount +
|
||||
recoveryTypeSummary[1].overdueCount +
|
||||
recoveryTypeSummary[2].overdueCount
|
||||
}}
|
||||
</td>
|
||||
<td class="num">₹{{ expectedRecoveryTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ actualRecoveryTotal | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.col-out]="recoveryVarianceTotal < 0">
|
||||
₹{{ recoveryVarianceTotal | number: '1.0-0' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Loan-wise Expected vs Actual Recovery</div>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Loan Type</th>
|
||||
<th class="num">Expected</th>
|
||||
<th class="num col-in">Actual</th>
|
||||
<th class="num">Variance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.customerName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>{{ repaymentTypeLabel(a.repaymentType) }}</td>
|
||||
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ actualThisMonth(a) | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.col-out]="recoveryVariance(a) < 0">
|
||||
₹{{ recoveryVariance(a) | number: '1.0-0' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="npa-badge"
|
||||
[class.npa-badge--standard]="recoveryVariance(a) >= 0"
|
||||
[class.npa-badge--substandard]="recoveryVariance(a) < 0"
|
||||
>
|
||||
{{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (loanWiseRecoveryAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of
|
||||
{{ loanWiseRecoveryAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, loanWiseRecoveryAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(loanWiseRecoveryAccounts)"
|
||||
(click)="goToPage(currentPage + 1, loanWiseRecoveryAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 12 DAY-WISE OVERDUE REPORT ===================== -->
|
||||
@if (activeReport === 'daywise-overdue') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Day-wise Overdue Report — Ageing Buckets</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Overdue Bucket</th>
|
||||
<th class="num">Accounts</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num col-out">Overdue Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of overdueAgeingBuckets; track b.label) {
|
||||
<tr>
|
||||
<td>{{ b.label }}</td>
|
||||
<td class="num">{{ b.count }}</td>
|
||||
<td class="num">₹{{ b.outstanding | number: '1.0-0' }}</td>
|
||||
<td class="num col-out">₹{{ b.overdueAmt | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Overdue Accounts — Day-wise, Marked by SMA Stage</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>Stage</th>
|
||||
<th class="num">Overdue Days</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num col-out">Overdue Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(overdueDaysAccounts); 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.overdueDays }}</td>
|
||||
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No overdue accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (overdueDaysAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of
|
||||
{{ overdueDaysAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, overdueDaysAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(overdueDaysAccounts)"
|
||||
(click)="goToPage(currentPage + 1, overdueDaysAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 13 DAY-WISE OVERDRAWN REPORT ===================== -->
|
||||
@if (activeReport === 'daywise-overdrawn') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts ({{ overdrawnBreachCount }} of
|
||||
{{ overdrawnAccounts.length }} breaching Drawing Power)
|
||||
</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 class="num">Drawing Power</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num col-out">Excess Over DP</th>
|
||||
<th class="num">Days Overdrawn</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(overdrawnAccounts); 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.drawingPower | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="npa-badge"
|
||||
[class.npa-badge--standard]="!overdrawnBreachOf(a)"
|
||||
[class.npa-badge--substandard]="overdrawnBreachOf(a)"
|
||||
>
|
||||
{{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No Overdrawn accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (overdrawnAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of
|
||||
{{ overdrawnAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, overdrawnAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(overdrawnAccounts)"
|
||||
(click)="goToPage(currentPage + 1, overdrawnAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
|
||||
@if (activeReport === 'agri-gold-value') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Agriculture / Gold Market Value Report — Current Security Value vs Loan</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 class="num">Outstanding</th>
|
||||
<th class="num">Collateral Value (At Sanction)</th>
|
||||
<th class="num">Market Value (Now)</th>
|
||||
<th class="num">Coverage %</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(agriGoldAccounts); 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">₹{{ a.collateralValue | number: '1.0-0' }}</td>
|
||||
<td
|
||||
class="num"
|
||||
[class.col-in]="a.collateralMarketValue >= a.collateralValue"
|
||||
[class.col-out]="a.collateralMarketValue < a.collateralValue"
|
||||
>
|
||||
₹{{ a.collateralMarketValue | number: '1.0-0' }}
|
||||
</td>
|
||||
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
|
||||
<td>
|
||||
<span
|
||||
class="npa-badge"
|
||||
[class.npa-badge--standard]="!undersecuredOf(a)"
|
||||
[class.npa-badge--substandard]="undersecuredOf(a)"
|
||||
>
|
||||
{{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No Agriculture / Gold accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (agriGoldAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of
|
||||
{{ agriGoldAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, agriGoldAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(agriGoldAccounts)"
|
||||
(click)="goToPage(currentPage + 1, agriGoldAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 15 EXCEPTION CHECK REPORT ===================== -->
|
||||
@if (activeReport === 'exception-check') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
|
||||
</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
|
||||
the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
|
||||
</p>
|
||||
<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>Stage</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num">Market Value</th>
|
||||
<th class="num col-out">Shortfall</th>
|
||||
<th class="num">Coverage %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(exceptionAccounts); 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.collateralMarketValue | number: '1.0-0' }}</td>
|
||||
<td class="num col-out">₹{{ securityShortfall(a) | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No undersecured accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (exceptionAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of
|
||||
{{ exceptionAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, exceptionAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(exceptionAccounts)"
|
||||
(click)="goToPage(currentPage + 1, exceptionAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ===================== Recovery / Collection Journey popup ===================== -->
|
||||
@if (showTimelineModal && selectedAccount; as a) {
|
||||
<div class="npa-timeline-backdrop" (click)="closeTimelineModal()">
|
||||
<div class="npa-timeline-modal" (click)="$event.stopPropagation()">
|
||||
<header class="npa-timeline-modal__head">
|
||||
<div class="npa-timeline-modal__title">
|
||||
<span class="npa-timeline-modal__eyebrow">Recovery / Collection Journey</span>
|
||||
<h2>{{ a.accountNo }} — {{ a.customerName }}</h2>
|
||||
<div class="npa-timeline-modal__meta">
|
||||
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||
<span class="npa-timeline-modal__branch">{{ a.branchName }}</span>
|
||||
<span class="npa-timeline-modal__count">{{ a.collectionTimeline.length }} touchpoints</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="npa-timeline-modal__close" (click)="closeTimelineModal()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</header>
|
||||
<div class="npa-timeline-modal__body">
|
||||
@if (a.collectionTimeline.length) {
|
||||
<ol class="npa-timeline">
|
||||
@for (e of a.collectionTimeline; track e.date + e.type) {
|
||||
<li [class]="'npa-timeline-item npa-timeline-item--' + timelineEventTone(e.type)">
|
||||
<span class="npa-timeline-item__icon"><fa-icon [icon]="timelineEventIcon(e.type)"></fa-icon></span>
|
||||
<div class="npa-timeline-item__body">
|
||||
<div class="npa-timeline-item__row">
|
||||
<span class="npa-timeline-item__type">{{ e.type }}</span>
|
||||
<span class="npa-timeline-item__date">{{ e.date }}</span>
|
||||
</div>
|
||||
<p class="npa-timeline-item__remarks">{{ e.remarks }}</p>
|
||||
<span class="npa-timeline-item__channel">{{ e.channel }}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<div class="npa-timeline-empty">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<p>No collection activity — this account has never gone overdue.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
@@ -41,7 +42,18 @@ import {
|
||||
undebitedInterestBalance,
|
||||
todayDDMMYYYY,
|
||||
LegalActionStatus,
|
||||
LEGAL_ACTION_LABELS
|
||||
LEGAL_ACTION_LABELS,
|
||||
RepaymentType,
|
||||
REPAYMENT_TYPE_LABELS,
|
||||
isBulletMatured,
|
||||
isBulletOverdue,
|
||||
isOverdrawnBreach,
|
||||
expectedRecoveryThisMonth,
|
||||
actualRecoveryThisMonth,
|
||||
securityCoverageRatio,
|
||||
isUndersecured,
|
||||
RecoveryEvent,
|
||||
RecoveryEventType
|
||||
} from '../npa-data';
|
||||
|
||||
interface StageSummaryRow {
|
||||
@@ -72,11 +84,27 @@ interface RecoveryRow {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface RepaymentTypeSummaryRow {
|
||||
type: RepaymentType;
|
||||
label: string;
|
||||
accounts: number;
|
||||
overdueCount: number;
|
||||
expected: number;
|
||||
actual: number;
|
||||
}
|
||||
|
||||
interface AgeingBucketRow {
|
||||
label: string;
|
||||
count: number;
|
||||
outstanding: number;
|
||||
overdueAmt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy "NPA" mega-menu — 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.
|
||||
* The legacy "NPA" mega-menu — screens across NPA Reports / NPA Analysis / NPA Classification /
|
||||
* Recovery Management — collapsed into one route-param-driven component so every menu item lands
|
||||
* on its own URL (/npa/reports/:reportId) with nothing left unwired. Every figure is derived from
|
||||
* the single LOAN_ACCOUNTS book in npa-data.ts, so a count on one report always agrees with another.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-npa-reports',
|
||||
@@ -90,7 +118,8 @@ interface RecoveryRow {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
RouterModule,
|
||||
FontAwesomeModule
|
||||
]
|
||||
})
|
||||
export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
@@ -123,6 +152,8 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
currentPage = 1;
|
||||
|
||||
selectedAccountNo = '';
|
||||
/** Opens the Recovery / Collection Journey popup for whichever account is currently selected. */
|
||||
showTimelineModal = false;
|
||||
|
||||
newRecovery = {
|
||||
accountNo: '',
|
||||
@@ -283,6 +314,106 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
return undebitedInterestBalance(a);
|
||||
}
|
||||
|
||||
// -------------------- Recovery Management System --------------------
|
||||
repaymentTypeLabel(type: RepaymentType): string {
|
||||
return REPAYMENT_TYPE_LABELS[type];
|
||||
}
|
||||
|
||||
expectedThisMonth(a: LoanAccount): number {
|
||||
return expectedRecoveryThisMonth(a);
|
||||
}
|
||||
|
||||
actualThisMonth(a: LoanAccount): number {
|
||||
return actualRecoveryThisMonth(a);
|
||||
}
|
||||
|
||||
recoveryVariance(a: LoanAccount): number {
|
||||
return this.actualThisMonth(a) - this.expectedThisMonth(a);
|
||||
}
|
||||
|
||||
/** The per-repayment-type "is this account currently irregular" check the note calls for:
|
||||
* an EMI shortfall this cycle, a drawing-power breach, or a matured-unpaid bullet loan. */
|
||||
isTypeOverdue(a: LoanAccount): boolean {
|
||||
if (a.repaymentType === 'OVERDRAWN') {
|
||||
return isOverdrawnBreach(a);
|
||||
}
|
||||
if (a.repaymentType === 'BULLET') {
|
||||
return isBulletOverdue(a);
|
||||
}
|
||||
return a.overdueDays > 0;
|
||||
}
|
||||
|
||||
overdrawnBreachOf(a: LoanAccount): boolean {
|
||||
return isOverdrawnBreach(a);
|
||||
}
|
||||
|
||||
excessOverDp(a: LoanAccount): number {
|
||||
return Math.max(0, a.outstandingBalance - a.drawingPower);
|
||||
}
|
||||
|
||||
bulletMaturedOf(a: LoanAccount): boolean {
|
||||
return isBulletMatured(a);
|
||||
}
|
||||
|
||||
bulletOverdueOf(a: LoanAccount): boolean {
|
||||
return isBulletOverdue(a);
|
||||
}
|
||||
|
||||
coverageRatio(a: LoanAccount): number {
|
||||
return securityCoverageRatio(a);
|
||||
}
|
||||
|
||||
undersecuredOf(a: LoanAccount): boolean {
|
||||
return isUndersecured(a);
|
||||
}
|
||||
|
||||
securityShortfall(a: LoanAccount): number {
|
||||
return Math.max(0, a.outstandingBalance - a.collateralMarketValue);
|
||||
}
|
||||
|
||||
/** CSS class for a collection-timeline event — "Payment Received" reads positive, formal
|
||||
* escalation steps (notice/agent) read as an alert, everything else is neutral follow-up. */
|
||||
/** Groups a timeline event into one of 3 visual tones for the popup's icon badge —
|
||||
* informational follow-up, an escalation step, or an actual payment. */
|
||||
timelineEventTone(type: RecoveryEventType): 'positive' | 'alert' | 'neutral' {
|
||||
if (type === 'Payment Received') {
|
||||
return 'positive';
|
||||
}
|
||||
if (type === 'Formal Overdue Letter' || type === 'Recovery Agent Assigned' || type === 'Demand Notice') {
|
||||
return 'alert';
|
||||
}
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
timelineEventIcon(type: RecoveryEventType): string {
|
||||
switch (type) {
|
||||
case 'SMS Reminder':
|
||||
return 'comment-dots';
|
||||
case 'IVR / Auto Call':
|
||||
case 'Tele-calling':
|
||||
case 'Overdue Call':
|
||||
return 'phone';
|
||||
case 'Field Visit':
|
||||
return 'map-marker-alt';
|
||||
case 'Formal Overdue Letter':
|
||||
return 'envelope';
|
||||
case 'Recovery Agent Assigned':
|
||||
return 'user-tie';
|
||||
case 'Demand Notice':
|
||||
return 'file-signature';
|
||||
case 'Payment Received':
|
||||
return 'check-circle';
|
||||
}
|
||||
}
|
||||
|
||||
openTimelineModal(): void {
|
||||
this.showTimelineModal = true;
|
||||
}
|
||||
|
||||
closeTimelineModal(): void {
|
||||
this.showTimelineModal = false;
|
||||
}
|
||||
|
||||
legalActionLabel(status: LegalActionStatus): string {
|
||||
return LEGAL_ACTION_LABELS[status];
|
||||
}
|
||||
@@ -395,6 +526,95 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
return this.restructuredAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
}
|
||||
|
||||
// -------------------- Recovery Management Dashboard --------------------
|
||||
get recoveryTypeSummary(): RepaymentTypeSummaryRow[] {
|
||||
const types: RepaymentType[] = [
|
||||
'EMI',
|
||||
'OVERDRAWN',
|
||||
'BULLET'
|
||||
];
|
||||
return types.map((type) => {
|
||||
const accts = this.allAccounts.filter((a) => a.repaymentType === type);
|
||||
return {
|
||||
type,
|
||||
label: REPAYMENT_TYPE_LABELS[type],
|
||||
accounts: accts.length,
|
||||
overdueCount: accts.filter((a) => this.isTypeOverdue(a)).length,
|
||||
expected: accts.reduce((s, a) => s + this.expectedThisMonth(a), 0),
|
||||
actual: accts.reduce((s, a) => s + this.actualThisMonth(a), 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get expectedRecoveryTotal(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0);
|
||||
}
|
||||
|
||||
get actualRecoveryTotal(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0);
|
||||
}
|
||||
|
||||
get recoveryVarianceTotal(): number {
|
||||
return this.actualRecoveryTotal - this.expectedRecoveryTotal;
|
||||
}
|
||||
|
||||
get recoveryPctTotal(): number {
|
||||
return this.expectedRecoveryTotal ? (this.actualRecoveryTotal / this.expectedRecoveryTotal) * 100 : 0;
|
||||
}
|
||||
|
||||
get loanWiseRecoveryAccounts(): LoanAccount[] {
|
||||
return [...this.allAccounts].sort((a, b) => this.recoveryVariance(a) - this.recoveryVariance(b));
|
||||
}
|
||||
|
||||
// -------------------- Day-wise Overdue Report --------------------
|
||||
get overdueAgeingBuckets(): AgeingBucketRow[] {
|
||||
const buckets: Array<{ label: string; test: (days: number) => boolean }> = [
|
||||
{ label: 'Current (0 Days)', test: (d) => d === 0 },
|
||||
{ label: '1–30 Days (SMA-0)', test: (d) => d >= 1 && d <= 30 },
|
||||
{ label: '31–60 Days (SMA-1)', test: (d) => d >= 31 && d <= 60 },
|
||||
{ label: '61–90 Days (SMA-2)', test: (d) => d >= 61 && d <= 90 },
|
||||
{ label: '90+ Days (NPA)', test: (d) => d > 90 }
|
||||
];
|
||||
return buckets.map((b) => {
|
||||
const accts = this.allAccounts.filter((a) => b.test(a.overdueDays));
|
||||
return {
|
||||
label: b.label,
|
||||
count: accts.length,
|
||||
outstanding: accts.reduce((s, a) => s + a.outstandingBalance, 0),
|
||||
overdueAmt: accts.reduce((s, a) => s + a.overdueAmount, 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get overdueDaysAccounts(): LoanAccount[] {
|
||||
return this.allAccounts.filter((a) => a.overdueDays > 0).sort((a, b) => b.overdueDays - a.overdueDays);
|
||||
}
|
||||
|
||||
// -------------------- Day-wise Overdrawn Report --------------------
|
||||
get overdrawnAccounts(): LoanAccount[] {
|
||||
return this.allAccounts
|
||||
.filter((a) => a.repaymentType === 'OVERDRAWN')
|
||||
.sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a));
|
||||
}
|
||||
|
||||
get overdrawnBreachCount(): number {
|
||||
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
|
||||
}
|
||||
|
||||
// -------------------- Agriculture / Gold Market Value Report --------------------
|
||||
get agriGoldAccounts(): LoanAccount[] {
|
||||
return this.allAccounts
|
||||
.filter((a) => a.productCode === '05' || a.productCode === '06')
|
||||
.sort((a, b) => this.coverageRatio(a) - this.coverageRatio(b));
|
||||
}
|
||||
|
||||
// -------------------- Exception Check Report --------------------
|
||||
get exceptionAccounts(): LoanAccount[] {
|
||||
return this.allAccounts
|
||||
.filter((a) => this.undersecuredOf(a))
|
||||
.sort((a, b) => this.securityShortfall(b) - this.securityShortfall(a));
|
||||
}
|
||||
|
||||
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
|
||||
get stageSummary(): StageSummaryRow[] {
|
||||
return STAGE_ORDER.map((stage) => {
|
||||
@@ -471,6 +691,7 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
searchAccount(): void {
|
||||
this.showTimelineModal = false;
|
||||
const a = this.selectedAccount;
|
||||
if (!a) {
|
||||
this.toast('No account found with that number.');
|
||||
@@ -897,6 +1118,29 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
r.mode,
|
||||
r.amount
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Collection Timeline',
|
||||
title: a ? `Recovery / Collection Journey — ${a.accountNo}` : 'Recovery / Collection Journey',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Date',
|
||||
'Event',
|
||||
'Channel',
|
||||
'Remarks'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
24,
|
||||
46
|
||||
],
|
||||
rows: (a?.collectionTimeline || []).map((e) => [
|
||||
e.date,
|
||||
e.type,
|
||||
e.channel,
|
||||
e.remarks
|
||||
])
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1157,6 +1401,272 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
];
|
||||
|
||||
case 'recovery-dashboard':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Recovery Summary',
|
||||
title: 'Expected vs Actual Recovery — This Cycle, by Loan Type',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Loan Type',
|
||||
'Accounts',
|
||||
'Overdue Now',
|
||||
'Expected Recovery',
|
||||
'Actual Recovery',
|
||||
'Variance'
|
||||
],
|
||||
widths: [
|
||||
20,
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
18,
|
||||
16
|
||||
],
|
||||
rows: [
|
||||
...this.recoveryTypeSummary.map((row) => [
|
||||
row.label,
|
||||
row.accounts,
|
||||
row.overdueCount,
|
||||
row.expected,
|
||||
row.actual,
|
||||
row.actual - row.expected
|
||||
]),
|
||||
[
|
||||
'Total',
|
||||
this.allAccounts.length,
|
||||
this.recoveryTypeSummary.reduce((s, row) => s + row.overdueCount, 0),
|
||||
this.expectedRecoveryTotal,
|
||||
this.actualRecoveryTotal,
|
||||
this.recoveryVarianceTotal
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
sheetName: 'Loan-wise Recovery',
|
||||
title: 'Loan-wise Expected vs Actual Recovery',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Loan Type',
|
||||
'Expected',
|
||||
'Actual',
|
||||
'Variance',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
rows: this.loanWiseRecoveryAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
this.repaymentTypeLabel(a.repaymentType),
|
||||
this.expectedThisMonth(a),
|
||||
this.actualThisMonth(a),
|
||||
this.recoveryVariance(a),
|
||||
this.recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall'
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
case 'daywise-overdue':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Ageing Buckets',
|
||||
title: 'Day-wise Overdue Report — Ageing Buckets',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Overdue Bucket',
|
||||
'Accounts',
|
||||
'Outstanding',
|
||||
'Overdue Amount'
|
||||
],
|
||||
widths: [
|
||||
22,
|
||||
12,
|
||||
18,
|
||||
18
|
||||
],
|
||||
rows: this.overdueAgeingBuckets.map((b) => [
|
||||
b.label,
|
||||
b.count,
|
||||
b.outstanding,
|
||||
b.overdueAmt
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Overdue Accounts',
|
||||
title: 'Overdue Accounts — Day-wise, Marked by SMA Stage',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Stage',
|
||||
'Overdue Days',
|
||||
'Outstanding',
|
||||
'Overdue Amount'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
12,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.overdueDaysAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
a.productName,
|
||||
this.stageLabel(this.stage(a)),
|
||||
a.overdueDays,
|
||||
a.outstandingBalance,
|
||||
a.overdueAmount
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
case 'daywise-overdrawn':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Overdrawn Accounts',
|
||||
title: 'Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Drawing Power',
|
||||
'Outstanding',
|
||||
'Excess Over DP',
|
||||
'Days Overdrawn',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
14
|
||||
],
|
||||
rows: this.overdrawnAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
a.productName,
|
||||
a.drawingPower,
|
||||
a.outstandingBalance,
|
||||
this.excessOverDp(a),
|
||||
this.overdrawnBreachOf(a) ? a.overdueDays : 0,
|
||||
this.overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit'
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
case 'agri-gold-value':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Agri-Gold Value',
|
||||
title: 'Agriculture / Gold Market Value Report — Current Security Value vs Loan',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Outstanding',
|
||||
'Collateral Value (At Sanction)',
|
||||
'Market Value (Now)',
|
||||
'Coverage %',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
22,
|
||||
18,
|
||||
12,
|
||||
14
|
||||
],
|
||||
rows: this.agriGoldAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
a.productName,
|
||||
a.outstandingBalance,
|
||||
a.collateralValue,
|
||||
a.collateralMarketValue,
|
||||
Number(this.coverageRatio(a).toFixed(0)),
|
||||
this.undersecuredOf(a) ? 'Undersecured' : 'Adequate'
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
case 'exception-check':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Exception Check',
|
||||
title: 'Exception Check — Security Value vs Loan',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Stage',
|
||||
'Outstanding',
|
||||
'Market Value',
|
||||
'Shortfall',
|
||||
'Coverage %'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
rows: this.exceptionAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
a.productName,
|
||||
this.stageLabel(this.stage(a)),
|
||||
a.outstandingBalance,
|
||||
a.collateralMarketValue,
|
||||
this.securityShortfall(a),
|
||||
Number(this.coverageRatio(a).toFixed(0))
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -88,6 +88,301 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery Management Dashboard's 3 loan-type panels — same .inv-kpi tile, just a 3-wide grid
|
||||
// instead of the portfolio strip's 4-wide one.
|
||||
.npa-kpis--three {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
|
||||
.npa-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.npa-timeline-trigger {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
|
||||
color: #4338ca;
|
||||
border: 1px solid #c7d2fe;
|
||||
font-weight: 700;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||||
box-shadow: 0 4px 14px rgb(67 56 202 / 22%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery / Collection Journey popup — a date-led timeline of every SMS, call,
|
||||
// visit, notice and payment run against one account, opened from the Account
|
||||
// Detail Report's "Recovery Journey" button.
|
||||
// ---------------------------------------------------------------------------
|
||||
.npa-timeline-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 55%);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
animation: npa-timeline-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.npa-timeline-modal {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
max-height: min(720px, 88vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 60px rgb(15 23 42 / 35%);
|
||||
animation: npa-timeline-slide-up 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc, #fff);
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__branch::before,
|
||||
&__count::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&__close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
overflow-y: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes npa-timeline-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes npa-timeline-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.npa-timeline-backdrop,
|
||||
.npa-timeline-modal {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// The timeline list itself — a connecting vertical line runs behind the icon badges.
|
||||
.npa-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.npa-timeline-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding-bottom: 22px;
|
||||
|
||||
// The connecting line spans from just under this item's icon to just above the next one's —
|
||||
// anchored to the item itself (not the fixed-size icon) via top+bottom, so it always reaches
|
||||
// exactly as far as the gap to the next entry, however tall this entry's text runs.
|
||||
&:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
bottom: -22px;
|
||||
left: 17px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--neutral &__icon {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&--alert &__icon {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--positive &__icon {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-weight: 700;
|
||||
font-size: 13.5px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__remarks {
|
||||
margin: 3px 0 6px;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&__channel {
|
||||
display: inline-block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.npa-timeline-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 40px 20px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
|
||||
fa-icon {
|
||||
font-size: 26px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.npa-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user