From b4608cbbe4c72c5a3d9d240c8e02d864c881eebd Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 2 Aug 2026 11:33:01 +0530 Subject: [PATCH] feat(npa): add Recovery Management System reports + Recovery Journey popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/core/shell/shell.component.html | 19 + src/app/core/shell/shell.component.scss | 7 + src/app/npa/npa-data.ts | 362 +++++++++++- .../npa-reports/npa-reports.component.html | 555 +++++++++++++++++- .../npa/npa-reports/npa-reports.component.ts | 522 +++++++++++++++- src/app/npa/npa-shared.scss | 295 ++++++++++ 6 files changed, 1737 insertions(+), 23 deletions(-) diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 58b9f1fc0..9e594590a 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1278,6 +1278,25 @@ SMA Report + +
+
Recovery Management
+ + + + + +
diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 44823418c..1dc00244e 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -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)); diff --git a/src/app/npa/npa-data.ts b/src/app/npa/npa-data.ts index 56fb6592d..c613e9a17 100644 --- a/src/app/npa/npa-data.ts +++ b/src/app/npa/npa-data.ts @@ -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 = { + 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 = { reports: 'NPA Reports', analysis: 'NPA Analysis', - classification: 'NPA Classification' + classification: 'NPA Classification', + 'recovery-mgmt': 'Recovery Management' }; 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 index e342273ae..98aaee3e4 100644 --- a/src/app/npa/npa-reports/npa-reports.component.html +++ b/src/app/npa/npa-reports/npa-reports.component.html @@ -75,7 +75,7 @@
- @for (g of ['reports', 'analysis', 'classification']; track g) { + @for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
@@ -1037,6 +1042,548 @@ }
} + + + @if (activeReport === 'recovery-dashboard') { +
+ +

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

+
+ @for (row of recoveryTypeSummary; track row.type) { +
+ {{ row.label }} + {{ row.accounts }} accounts + {{ row.overdueCount }} overdue this cycle +
+ } +
+
+ +
+ + + + + + + + + + + + + + @for (row of recoveryTypeSummary; track row.type) { + + + + + + + + + } + + + + + + + + + + + +
Loan TypeAccountsOverdue NowExpected RecoveryActual RecoveryVariance
{{ row.label }}{{ row.accounts }}{{ row.overdueCount }}₹{{ row.expected | number: '1.0-0' }}₹{{ row.actual | number: '1.0-0' }} + ₹{{ row.actual - row.expected | number: '1.0-0' }} +
Total{{ allAccounts.length }} + {{ + recoveryTypeSummary[0].overdueCount + + recoveryTypeSummary[1].overdueCount + + recoveryTypeSummary[2].overdueCount + }} + ₹{{ expectedRecoveryTotal | number: '1.0-0' }}₹{{ actualRecoveryTotal | number: '1.0-0' }} + ₹{{ recoveryVarianceTotal | number: '1.0-0' }} +
+

+ Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues. +

+
+ +
+ +
+ + + + + + + + + + + + + + + @for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) { + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchLoan TypeExpectedActualVarianceStatus
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ repaymentTypeLabel(a.repaymentType) }}₹{{ expectedThisMonth(a) | number: '1.0-0' }}₹{{ actualThisMonth(a) | number: '1.0-0' }} + ₹{{ recoveryVariance(a) | number: '1.0-0' }} + + + {{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }} + +
No accounts in this branch.
+
+ @if (loanWiseRecoveryAccounts.length > pageSize) { +
+ Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of + {{ loanWiseRecoveryAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'daywise-overdue') { +
+ + + + + + + + + + + + @for (b of overdueAgeingBuckets; track b.label) { + + + + + + + } + +
Overdue BucketAccountsOutstandingOverdue Amount
{{ b.label }}{{ b.count }}₹{{ b.outstanding | number: '1.0-0' }}₹{{ b.overdueAmt | number: '1.0-0' }}
+
+ +
+ +
+ + + + + + + + + + + + + + + @for (a of paged(overdueDaysAccounts); track a.accountNo) { + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchProductStageOverdue DaysOutstandingOverdue Amount
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }} + {{ stageLabel(stage(a)) }} + {{ a.overdueDays }}₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.overdueAmount | number: '1.0-0' }}
No overdue accounts in this branch.
+
+ @if (overdueDaysAccounts.length > pageSize) { +
+ Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of + {{ overdueDaysAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'daywise-overdrawn') { +
+ +
+ + + + + + + + + + + + + + + + @for (a of paged(overdrawnAccounts); track a.accountNo) { + + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchProductDrawing PowerOutstandingExcess Over DPDays OverdrawnStatus
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }}₹{{ a.drawingPower | number: '1.0-0' }}₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ excessOverDp(a) | number: '1.0-0' }}{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }} + + {{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }} + +
No Overdrawn accounts in this branch.
+
+ @if (overdrawnAccounts.length > pageSize) { +
+ Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of + {{ overdrawnAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'agri-gold-value') { +
+ +
+ + + + + + + + + + + + + + + + @for (a of paged(agriGoldAccounts); track a.accountNo) { + + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchProductOutstandingCollateral Value (At Sanction)Market Value (Now)Coverage %Status
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }}₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.collateralValue | number: '1.0-0' }} + ₹{{ a.collateralMarketValue | number: '1.0-0' }} + {{ coverageRatio(a) | number: '1.0-0' }}% + + {{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }} + +
No Agriculture / Gold accounts in this branch.
+
+ @if (agriGoldAccounts.length > pageSize) { +
+ Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of + {{ agriGoldAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }} + +
+
+ } +
+ } + + + @if (activeReport === 'exception-check') { +
+ +

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

+
+ + + + + + + + + + + + + + + + @for (a of paged(exceptionAccounts); track a.accountNo) { + + + + + + + + + + + + } @empty { + + + + } + +
Account NoCustomerBranchProductStageOutstandingMarket ValueShortfallCoverage %
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }}{{ a.productName }} + {{ stageLabel(stage(a)) }} + ₹{{ a.outstandingBalance | number: '1.0-0' }}₹{{ a.collateralMarketValue | number: '1.0-0' }}₹{{ securityShortfall(a) | number: '1.0-0' }}{{ coverageRatio(a) | number: '1.0-0' }}%
No undersecured accounts in this branch.
+
+ @if (exceptionAccounts.length > pageSize) { +
+ Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of + {{ exceptionAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }} + +
+
+ } +
+ } + +@if (showTimelineModal && selectedAccount; as a) { +
+
+
+
+ Recovery / Collection Journey +

{{ a.accountNo }} — {{ a.customerName }}

+
+ {{ stageLabel(stage(a)) }} + {{ a.branchName }} + {{ a.collectionTimeline.length }} touchpoints +
+
+ +
+
+ @if (a.collectionTimeline.length) { +
    + @for (e of a.collectionTimeline; track e.date + e.type) { +
  1. + +
    +
    + {{ e.type }} + {{ e.date }} +
    +

    {{ e.remarks }}

    + {{ e.channel }} +
    +
  2. + } +
+ } @else { +
+ +

No collection activity — this account has never gone overdue.

+
+ } +
+
+
+} +
{{ toastMsg }}
diff --git a/src/app/npa/npa-reports/npa-reports.component.ts b/src/app/npa/npa-reports/npa-reports.component.ts index 5f0402336..6bf33e1f5 100644 --- a/src/app/npa/npa-reports/npa-reports.component.ts +++ b/src/app/npa/npa-reports/npa-reports.component.ts @@ -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 []; } diff --git a/src/app/npa/npa-shared.scss b/src/app/npa/npa-shared.scss index 9a911491b..419e908f0 100644 --- a/src/app/npa/npa-shared.scss +++ b/src/app/npa/npa-shared.scss @@ -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));