diff --git a/angular.json b/angular.json index 1fe5293cf..341741725 100644 --- a/angular.json +++ b/angular.json @@ -69,8 +69,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "13mb", - "maximumError": "14mb" + "maximumWarning": "15mb", + "maximumError": "17mb" }, { "type": "anyComponentStyle", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index dfd90efed..d970dca36 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -68,6 +68,7 @@ import { GlReportsModule } from './gl-reports/gl-reports.module'; import { SftModule } from './sft/sft.module'; import { InvestmentModule } from './investment/investment.module'; import { NpaModule } from './npa/npa.module'; +import { ReconciliationModule } from './reconciliation/reconciliation.module'; import { DigitalModule } from './digital/digital.module'; import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module'; import { AccountsModule } from './accounts/accounts.module'; @@ -178,6 +179,7 @@ export function HttpLoaderFactory(http: HttpClient) { SftModule, InvestmentModule, NpaModule, + ReconciliationModule, DigitalModule, LetterMgmtModule, AccountsModule, diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html index b4656e131..f7e1ec982 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html @@ -31,6 +31,9 @@ + @@ -209,43 +212,61 @@ ? 'Clearing' : 'Digital' }} - — Transaction Details + — Transaction Details (Full Double Entry) @if (glFilter !== null) { · {{ glFilter }} } - +
- - - + + + @for (t of filteredTxns; track t.voucherNo) { - + - - - - + - + + + + + + + + + + } @empty { @@ -255,9 +276,14 @@ - - - + + + +
Date Voucher / Ref NoAccount NoAccount Name / ReferenceParticularsDr / CrGL / Account Debit CreditStatus
{{ asOnDate }} {{ display(t.voucherNo) }} {{ display(t.accountNo) }}{{ t.accountName }}{{ t.particulars }} + Dr @if (t.debit > 0) { - ₹{{ t.debit | number: '1.2-2' }} + {{ t.glCode }} {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{ + t.accountName + }}) + } @else { + {{ contraLabel(t) }} } + ₹{{ legAmount(t) | number: '1.2-2' }} + ✓ Balanced +
Cr @if (t.credit > 0) { - ₹{{ t.credit | number: '1.2-2' }} + {{ t.glCode }} {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{ + t.accountName + }}) + } @else { + {{ contraLabel(t) }} } ₹{{ legAmount(t) | number: '1.2-2' }}
Total₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}Total ({{ filteredTxns.length }} vouchers) + ₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }} + + ₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }} +
diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss index e6aa08e9f..19552a68d 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss @@ -71,3 +71,68 @@ background: #eff6ff; } } + +// Both journal lines of one voucher share a tint and sit flush against each other (no dotted +// divider between them) so the pair reads as a single entry; a hairline separates one voucher's +// pair from the next. +.cctr-journal-row { + &--first td { + border-bottom: none; + padding-bottom: 4px; + } + + &--second td { + border-top: none; + padding-top: 4px; + border-bottom: 1px solid #e2e8f0; + } + + &--first, + &--second { + &:hover td { + background: #f8fafc; + } + } +} + +.cctr-drcr { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + padding: 1px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; + + &--dr { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + } + + &--cr { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } +} + +.cctr-contra { + color: #64748b; + font-style: italic; +} + +.cctr-balanced-chip { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + white-space: nowrap; +} diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts index 0399b5d66..65fbf5d25 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { ExportSheetSpec, exportWorkbook } from '../cashier-export'; interface SummaryRow { code: string; @@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent { showDetails = false; /** GL/channel filter for the Transaction Details view — null means "All". */ glFilter: string | null = null; + /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */ + generating = false; + + private readonly tabLabels: Record = { + cash: 'Cash', + transfer: 'Transfer', + clearing: 'Clearing', + digital: 'Digital' + }; /** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */ isHeadOffice = false; @@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent { return this.activeTab === 'digital' ? row.code : row.name; } + /** GL name for the code tracked on this txn row, so the Transaction Details journal can show + * "which GL" alongside the account, not just a bare code. */ + glName(code: string): string { + return this.rowsForActiveTab.find((r) => r.code === code)?.name || code; + } + + /** + * Every transaction here is only ever recorded as ONE leg (whichever GL this Cash/Clearing/ + * Transfer/Digital screen tracks) — the offsetting leg (the customer's own account, a + * settlement/clearing-house account, etc.) was never modelled as a separate GL row. Rather + * than leaving that side blank, this derives a sensible label for it so the Transaction + * Details view can show the FULL double entry (Dr leg + Cr leg, always equal amounts) — + * exactly what a real CBS "Day Book"/Journal Register shows, and what makes it possible to + * see at a glance that a transaction is complete and ready for reconciliation. + * + * For internal cash-movement postings (ATM cash-in-transit, currency chest, denomination + * shortages, CTS grid rounding) the account name already describes the SAME leg rather than a + * distinct counter-party, so those get a purpose-built contra label. Everything else already + * has a descriptive counter-party name (customer, vendor, bank branch, government department) + * that IS the natural contra account, so it's used as-is — WITHOUT repeating the account/ + * reference number, since that number is already shown against the tracked GL leg on the + * other line; showing it a second time here would make both lines look like the same account, + * defeating the point of a "Balanced" two-sided entry. + */ + contraLabel(t: TxnRow): string { + const p = t.particulars.toLowerCase(); + if (p.includes('atm') && (p.includes('vault') || p.includes('loading') || p.includes('surrendered'))) { + return 'ATM Cash-in-Transit Account'; + } + if (p.includes('currency chest')) { + return 'Currency Chest Account (Sponsor Bank)'; + } + if (p.includes('denomination') || p.includes('shortage adjustment')) { + return 'Cash Difference / Shortage Suspense Account'; + } + if (p.includes('cts grid rounding')) { + return 'CTS Grid Rounding Suspense Account'; + } + return t.accountName; + } + + /** The single non-zero amount on this leg — always what the derived contra leg mirrors. */ + legAmount(t: TxnRow): number { + return t.debit > 0 ? t.debit : t.credit; + } + /** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */ rowCredit(row: SummaryRow, txns: TxnRow[]): number { return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0); @@ -1566,4 +1622,181 @@ export class CashClearingTransferSummaryComponent { return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit }; }); } + + /** + * Exports the full report for the currently viewed scope (own branch, a drilled-into branch, + * or "All Branches" consolidated) — every category (Cash/Transfer/Clearing/Digital), not just + * the tab on screen, so the workbook is a complete record. "Summary" mirrors the Opening/ + * Credit/Debit/Closing view; "Transaction Details" mirrors the full double-entry journal view + * (same glName/contraLabel/legAmount derivations, so the export can never disagree with what's + * on screen); "Branch Breakup" is added only when Head Office is viewing the consolidated view. + */ + async exportExcel(): Promise { + this.generating = true; + try { + const summaryRows: (string | number | null)[][] = []; + const journalRows: (string | number | null)[][] = []; + + for (const tab of this.allTabs) { + const label = this.tabLabels[tab]; + const rows = this.effectiveRows(tab); + const txns = this.effectiveTxns(tab); + + rows.forEach((r) => { + summaryRows.push([ + label, + r.code, + r.name, + r.opening, + this.rowCredit(r, txns), + this.rowDebit(r, txns), + this.closing(r, txns) + ]); + }); + summaryRows.push([ + `${label} Total`, + '', + '', + this.total(rows, txns, 'opening'), + this.total(rows, txns, 'credit'), + this.total(rows, txns, 'debit'), + this.totalClosing(rows, txns) + ]); + + txns.forEach((t) => { + const glText = rows.find((r) => r.code === t.glCode)?.name || t.glCode; + journalRows.push([ + label, + this.asOnDate, + this.display(t.voucherNo), + 'Dr', + t.debit > 0 ? t.glCode : '', + t.debit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t), + this.legAmount(t), + null, + 'Balanced' + ]); + journalRows.push([ + label, + this.asOnDate, + this.display(t.voucherNo), + 'Cr', + t.credit > 0 ? t.glCode : '', + t.credit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t), + null, + this.legAmount(t), + 'Balanced' + ]); + }); + } + summaryRows.push([ + 'Grand Total', + '', + '', + this.grandOpening, + this.grandCredit, + this.grandDebit, + this.grandClosing + ]); + + const sheets: ExportSheetSpec[] = [ + { + sheetName: 'Summary', + title: 'Cash / Clearing / Transfer - Summary Report', + subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`, + headers: [ + 'Category', + 'GL Code', + 'Particulars', + 'Opening', + 'Credit', + 'Debit', + 'Closing' + ], + widths: [ + 12, + 12, + 34, + 16, + 16, + 16, + 16 + ], + rows: summaryRows + }, + { + sheetName: 'Transaction Details', + title: 'Transaction Details — Full Double Entry', + subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`, + headers: [ + 'Category', + 'Date', + 'Voucher / Ref No', + 'Dr / Cr', + 'GL Code', + 'GL / Account', + 'Debit', + 'Credit', + 'Status' + ], + widths: [ + 12, + 12, + 20, + 8, + 10, + 46, + 16, + 16, + 12 + ], + rows: journalRows + } + ]; + + if (this.isHeadOffice && this.selectedBranchCode === 'ALL') { + sheets.push({ + sheetName: 'Branch Breakup', + title: 'Branch-wise Break-up', + subtitle: `As On: ${this.asOnDate}`, + headers: [ + 'Branch', + 'Opening', + 'Credit', + 'Debit', + 'Closing' + ], + widths: [ + 22, + 16, + 16, + 16, + 16 + ], + rows: [ + ...this.branchBreakup.map((b) => [ + b.name, + b.opening, + b.credit, + b.debit, + b.closing + ]), + [ + 'All Branches', + this.grandOpening, + this.grandCredit, + this.grandDebit, + this.grandClosing + ] + ] + }); + } + + await exportWorkbook(sheets, `Cash-Clearing-Transfer-Summary-${this.scopeName}-${this.asOnDate}`); + } catch (err) { + console.error('Cash/Clearing/Transfer export failed', err); + } finally { + this.generating = false; + } + } } diff --git a/src/app/cashier/cashier-export.ts b/src/app/cashier/cashier-export.ts new file mode 100644 index 000000000..813e33c2b --- /dev/null +++ b/src/app/cashier/cashier-export.ts @@ -0,0 +1,88 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts) +// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet +// names, Blob download) is written once and every cashier report's export behaves the same way. + +import * as ExcelJS from 'exceljs'; + +export interface ExportSheetSpec { + /** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */ + sheetName: string; + /** Bold title row, merged across all columns. */ + title: string; + /** Optional italic subtitle row (e.g. scope/date), merged across all columns. */ + subtitle?: string; + headers: string[]; + rows: (string | number | null)[][]; + widths?: number[]; +} + +/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */ +function safeSheetName(name: string): string { + return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31); +} + +function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void { + const sheet = wb.addWorksheet(safeSheetName(spec.sheetName)); + sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w })); + + const title = sheet.addRow([spec.title]); + sheet.mergeCells(title.number, 1, title.number, spec.headers.length); + title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } }; + title.alignment = { horizontal: 'center', vertical: 'middle' }; + title.height = 24; + title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } }; + + if (spec.subtitle) { + const meta = sheet.addRow([spec.subtitle]); + sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length); + meta.font = { italic: true, size: 10 }; + } + sheet.addRow([]); + + const head = sheet.addRow(spec.headers); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => { + c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; + c.border = { bottom: { style: 'thin' } }; + }); + + for (const r of spec.rows) { + const row = sheet.addRow(r); + r.forEach((v, idx) => { + if (typeof v === 'number') { + row.getCell(idx + 1).numFmt = '#,##0.00'; + } + }); + } + sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }]; +} + +/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */ +export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBSNexora'; + wb.created = new Date(); + sheets.forEach((s) => buildSheet(wb, s)); + + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index b3948a6a9..d5598fa1e 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1295,6 +1295,9 @@ +
@@ -1306,6 +1309,25 @@ SMA Report
+ +
+
Recovery Management
+ + + + + +
@@ -1337,27 +1359,27 @@
Core & Books
- - - - - - @@ -1366,19 +1388,19 @@
Bank & Clearing
- - - - @@ -1387,15 +1409,15 @@
Digital & Payments
- - - @@ -1404,11 +1426,11 @@
Instruments & Members
- - @@ -1417,11 +1439,11 @@
Compliance & Financials
- - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 79000306a..1dc00244e 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -677,6 +677,26 @@ $gray-700: #334155; opacity: 0.7; } +// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired +// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since +// mat-menu-item's internal layout isn't a plain flex row we control directly. +[mat-menu-item].menu-item-live { + position: relative; + + &::after { + content: ''; + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background: #16a34a; + box-shadow: 0 0 0 2px rgb(22 163 74 / 15%); + } +} + // ===== Nav Dropdown Menus ===== ::ng-deep .nav-dropdown { border-radius: 8px !important; @@ -729,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/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index 10f310bd8..d2561a1d6 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -25,6 +25,11 @@ } + @if (activeTab === 'holdings') { + + }
diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index de971e980..9b65964c2 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -43,6 +43,7 @@ import { yearsBetween, todayDDMMYYYY } from '../investment-data'; +import { exportWorkbook } from '../investment-export'; type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND'; type BondTab = @@ -210,6 +211,7 @@ export class BondInvestmentComponent { readonly intTypeOptions = INT_TYPE_OPTIONS; readonly authorityOptions = AUTHORITY_OPTIONS; + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -1092,6 +1094,68 @@ export class BondInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook( + [ + { + sheetName: this.instrumentLabel(this.bondType), + title: `${this.instrumentLabel(this.bondType)} Holdings Register`, + subtitle: `As on ${this.asOnDate}`, + headers: [ + 'Cert No', + 'Script', + 'Party', + 'Units', + 'Face Value', + 'Rate', + 'Purchase Date', + 'Maturity Date', + 'Accrued Interest', + 'Maturity Value', + 'Status' + ], + widths: [ + 14, + 26, + 22, + 12, + 16, + 10, + 14, + 14, + 16, + 16, + 12 + ], + rows: this.holdingsForType.map((h) => [ + h.certNo, + h.scriptName, + h.party, + h.units, + h.faceValue, + h.interestRate, + h.purchaseDate, + h.maturityDate, + this.accrued(h), + this.maturity(h), + h.status + ]) + } + ], + `${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}` + ); + this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`); + } catch (err) { + console.error('Bond Holdings export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/investment/fd-investment/fd-investment.component.html b/src/app/investment/fd-investment/fd-investment.component.html index bac98204f..fffa89741 100644 --- a/src/app/investment/fd-investment/fd-investment.component.html +++ b/src/app/investment/fd-investment/fd-investment.component.html @@ -16,6 +16,13 @@ Part-Payment entries.

+ @if (activeTab === 'holdings') { +
+ +
+ }
diff --git a/src/app/investment/fd-investment/fd-investment.component.ts b/src/app/investment/fd-investment/fd-investment.component.ts index 739adf032..0d3c10226 100644 --- a/src/app/investment/fd-investment/fd-investment.component.ts +++ b/src/app/investment/fd-investment/fd-investment.component.ts @@ -26,6 +26,7 @@ import { addPeriod, todayDDMMYYYY } from '../investment-data'; +import { exportWorkbook } from '../investment-export'; type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment'; @@ -103,6 +104,7 @@ export class FdInvestmentComponent { 'BRANCH 2' ]; + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -450,6 +452,68 @@ export class FdInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook( + [ + { + sheetName: 'FD Holdings', + title: 'FD Holdings Register', + subtitle: `As on ${this.asOnDate}`, + headers: [ + 'Cert No', + 'Party', + 'Type', + 'Face Value', + 'Rate', + 'Frequency', + 'Purchase Date', + 'Maturity Date', + 'Accrued Interest', + 'Maturity Value', + 'Status' + ], + widths: [ + 14, + 28, + 18, + 16, + 10, + 14, + 14, + 14, + 16, + 16, + 12 + ], + rows: this.holdings.map((h) => [ + h.certNo, + h.party, + h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout', + h.faceValue, + h.interestRate, + h.interestFreq, + h.purchaseDate, + h.maturityDate, + this.accrued(h), + this.maturity(h), + h.status + ]) + } + ], + `FD-Holdings-${this.asOnDate}` + ); + this.toast('FD Holdings exported.'); + } catch (err) { + console.error('FD Holdings export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.html b/src/app/investment/investment-dashboard/investment-dashboard.component.html index 43309fba5..36daffc84 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.html +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.html @@ -16,6 +16,11 @@ {{ asOnDate }}.

+
+ +
@@ -78,6 +83,58 @@
+
+ + + + + + + + + + + + + + @for (a of allocationVsTarget; track a.type) { + + + + + + + + + } + + + + + + + + + + +
Instrument TypeTarget %Actual %Actual ValueVarianceStatus
{{ a.label }}{{ a.targetPct }}%{{ a.actualPct }}%₹{{ a.actualValue | number: '1.2-2' }} + {{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}% + + + {{ a.statusLabel }} + +
Total100%100%₹{{ totalFaceValue | number: '1.2-2' }}
+
+
@if (maturingSoon.length === 0) { @@ -144,3 +201,5 @@
+ +
{{ toastMsg }}
diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.ts b/src/app/investment/investment-dashboard/investment-dashboard.component.ts index 650d5f568..5c6166a82 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.ts +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.ts @@ -14,10 +14,12 @@ import { INSTRUMENT_LABELS, InstrumentType, InvestmentHolding, + TARGET_ALLOCATION_PCT, accruedInterest, daysToMaturity, maturityAmount } from '../investment-data'; +import { exportWorkbook } from '../investment-export'; interface InstrumentSummary { type: InstrumentType; @@ -29,6 +31,16 @@ interface InstrumentSummary { maturityValue: number; } +interface AllocationRow { + type: InstrumentType; + label: string; + targetPct: number; + actualPct: number; + actualValue: number; + variancePct: number; + statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated'; +} + /** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never * hand-duplicated, so it can never drift from the per-instrument screens or Reports. */ @Component({ @@ -46,6 +58,10 @@ interface InstrumentSummary { }) export class InvestmentDashboardComponent { asOnDate = ''; + generating = false; + toastMsg = ''; + toastVisible = false; + private toastTimer: ReturnType | undefined; private readonly types: Array<{ type: InstrumentType; route: string }> = [ { type: 'FD', route: '/investment/fd' }, { type: 'GOVT_SECURITY', route: '/investment/bonds' }, @@ -79,6 +95,30 @@ export class InvestmentDashboardComponent { }); } + /** Board-approved policy target % per instrument type vs the portfolio's actual allocation + * (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since + * a treasury desk can't rebalance to the exact decimal every day. */ + get allocationVsTarget(): AllocationRow[] { + const total = this.totalFaceValue; + return this.types.map(({ type }) => { + const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0); + const actualPct = total ? (value / total) * 100 : 0; + const targetPct = TARGET_ALLOCATION_PCT[type]; + const variancePct = +(actualPct - targetPct).toFixed(1); + const statusLabel: AllocationRow['statusLabel'] = + Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated'; + return { + type, + label: INSTRUMENT_LABELS[type], + targetPct, + actualPct: +actualPct.toFixed(1), + actualValue: value, + variancePct, + statusLabel + }; + }); + } + get totalHoldings(): number { return ALL_HOLDINGS.length; } @@ -117,4 +157,85 @@ export class InvestmentDashboardComponent { instrumentLabel(type: InstrumentType): string { return INSTRUMENT_LABELS[type]; } + + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook( + [ + { + sheetName: 'Instrument Breakup', + title: 'Investment Portfolio — Instrument-wise Break-up', + subtitle: `As on ${this.asOnDate}`, + headers: [ + 'Instrument Type', + 'Holdings', + 'Face Value', + 'Accrued Interest', + 'Maturity Value' + ], + widths: [ + 26, + 12, + 18, + 18, + 18 + ], + rows: this.summaries.map((s) => [ + s.label, + s.count, + s.faceValue, + s.accruedInterest, + s.maturityValue + ]) + }, + { + sheetName: 'Portfolio Allocation', + title: 'Portfolio Allocation — Target vs Actual', + subtitle: `As on ${this.asOnDate}`, + headers: [ + 'Instrument Type', + 'Target %', + 'Actual %', + 'Actual Value', + 'Variance %', + 'Status' + ], + widths: [ + 26, + 12, + 12, + 18, + 12, + 18 + ], + rows: this.allocationVsTarget.map((a) => [ + a.label, + a.targetPct, + a.actualPct, + a.actualValue, + a.variancePct, + a.statusLabel + ]) + } + ], + `Investment-Dashboard-${this.asOnDate}` + ); + this.toast('Investment Dashboard exported.'); + } catch (err) { + console.error('Investment Dashboard export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + + private toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + if (this.toastTimer) { + clearTimeout(this.toastTimer); + } + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } } diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index a2cdd253c..980093c86 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | export type TxnType = | 'PURCHASE' | 'INTEREST' + | 'INTEREST_RECEIVED' | 'DIVIDEND' | 'SELL' | 'REDEEM' @@ -38,6 +39,32 @@ export const INSTRUMENT_LABELS: Record = { SHARE: 'Share' }; +/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or + * explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss — + * a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no + * longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held + * against the investment (see provisionRequiredInv()). */ +export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS'; + +export const PROVISION_STATUS_LABELS: Record = { + STANDARD: 'Standard', + SUB_STANDARD: 'Sub-Standard (NPI)', + DOUBTFUL: 'Doubtful (NPI)', + LOSS: 'Loss (NPI)' +}; + +/** Board-approved portfolio-policy target — % of the total investment corpus each instrument + * type should hold. Compared against actual holdings on the Dashboard so a treasury desk can + * see at a glance where the book has drifted from policy. */ +export const TARGET_ALLOCATION_PCT: Record = { + FD: 15, + GOVT_SECURITY: 35, + NON_SLR: 10, + BOND: 20, + MUTUAL_FUND: 12, + SHARE: 8 +}; + export interface InvestmentHolding { certNo: string; instrumentType: InstrumentType; @@ -57,6 +84,12 @@ export interface InvestmentHolding { crToAc: string; brokerCode: string; status: HoldingStatus; + /** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */ + provisionStatus?: InvestmentProvisionStatus; + /** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */ + interestPosted?: number; + /** Cumulative interest actually received in cash to date. */ + interestReceived?: number; } export interface InvestmentTxn { @@ -109,6 +142,67 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number { return Math.round((mat - asOn) / (24 * 3600 * 1000)); } +// -------------------- Provisioning & interest-lifecycle helpers -------------------- + +export function isNpi(h: InvestmentHolding): boolean { + return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD'; +} + +export function provisionRatePctInv(status: InvestmentProvisionStatus): number { + switch (status) { + case 'STANDARD': + return 0; + case 'SUB_STANDARD': + return 25; + case 'DOUBTFUL': + return 50; + case 'LOSS': + return 100; + } +} + +/** Provision required against the investment itself (principal), same idea as NPA provisioning + * on a loan book — computed on face value since that is this module's carrying-value figure. */ +export function provisionRequiredInv(h: InvestmentHolding): number { + return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100)); +} + +/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry) + * while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/ + * Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e. + * it is recognised only when actually received, never posted as an accrual receivable. */ +export function canAccrueInterest(h: InvestmentHolding): boolean { + return !isNpi(h); +} + +/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting + * an accrual on a non-performing investment would overstate income. */ +export function interestToPost(h: InvestmentHolding, asOnStr: string): number { + if (!canAccrueInterest(h)) { + return 0; + } + return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0)); +} + +/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income) + * but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever + * posted as a receivable on one. */ +export function interestReceivableBalance(h: InvestmentHolding): number { + if (!canAccrueInterest(h)) { + return 0; + } + return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0)); +} + +/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never + * posted as an asset or recognised as income — until it is actually collected in cash. */ +export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number { + if (canAccrueInterest(h)) { + return 0; + } + return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0)); +} + export function formatDDMMYYYY(d: Date): string { return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; } @@ -208,7 +302,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [ glName: 'FIXED DEPO. CO-OPER. BANK MAIN', crToAc: '310070', brokerCode: '', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 42000, + interestReceived: 36000 }, { certNo: '15624', @@ -268,7 +364,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [ glName: 'FIXED DEPO. CO-OPER. BANK MAIN', crToAc: '310070', brokerCode: '', - status: 'ACTIVE' + status: 'ACTIVE', + provisionStatus: 'DOUBTFUL', + interestPosted: 115500, + interestReceived: 50000 }, { certNo: '15627', @@ -311,7 +410,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN GOVT. SECURITIES', crToAc: '310070', brokerCode: 'BRK-002', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 181500, + interestReceived: 150000 }, { certNo: 'GSEC-2002', @@ -351,7 +452,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN GOVT. SECURITIES', crToAc: '310070', brokerCode: 'BRK-002', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 67900, + interestReceived: 67900 }, { certNo: 'GSEC-2004', @@ -514,7 +617,9 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN NON SLR SECURITIES', crToAc: '310070', brokerCode: 'BRK-003', - status: 'ACTIVE' + status: 'ACTIVE', + interestPosted: 89400, + interestReceived: 60000 } ]; @@ -597,7 +702,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [ glName: 'INVESTMENT IN CORPORATE BONDS', crToAc: '310070', brokerCode: 'BRK-006', - status: 'ACTIVE' + status: 'ACTIVE', + provisionStatus: 'SUB_STANDARD', + interestPosted: 66000, + interestReceived: 40000 }, { certNo: 'BOND-4005', diff --git a/src/app/investment/investment-export.ts b/src/app/investment/investment-export.ts new file mode 100644 index 000000000..b04f1c2ec --- /dev/null +++ b/src/app/investment/investment-export.ts @@ -0,0 +1,89 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Shared "Export to Excel" builder for every listing/report screen in the Investment module +// (Dashboard, Reports, Interest, Holdings, Masters) — centralised so the ExcelJS boilerplate +// (title/meta/header rows, number formatting, safe sheet names, Blob download) is written once +// and every screen's export always behaves and looks the same. + +import * as ExcelJS from 'exceljs'; + +export interface ExportSheetSpec { + /** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */ + sheetName: string; + /** Bold title row, merged across all columns. */ + title: string; + /** Optional italic subtitle row (e.g. scope/date), merged across all columns. */ + subtitle?: string; + headers: string[]; + rows: (string | number | null)[][]; + widths?: number[]; +} + +/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */ +function safeSheetName(name: string): string { + return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31); +} + +function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void { + const sheet = wb.addWorksheet(safeSheetName(spec.sheetName)); + sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w })); + + const title = sheet.addRow([spec.title]); + sheet.mergeCells(title.number, 1, title.number, spec.headers.length); + title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } }; + title.alignment = { horizontal: 'center', vertical: 'middle' }; + title.height = 24; + title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } }; + + if (spec.subtitle) { + const meta = sheet.addRow([spec.subtitle]); + sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length); + meta.font = { italic: true, size: 10 }; + } + sheet.addRow([]); + + const head = sheet.addRow(spec.headers); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => { + c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; + c.border = { bottom: { style: 'thin' } }; + }); + + for (const r of spec.rows) { + const row = sheet.addRow(r); + r.forEach((v, idx) => { + if (typeof v === 'number') { + row.getCell(idx + 1).numFmt = '#,##0.00'; + } + }); + } + sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }]; +} + +/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */ +export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBSNexora'; + wb.created = new Date(); + sheets.forEach((s) => buildSheet(wb, s)); + + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/app/investment/investment-interest/investment-interest.component.html b/src/app/investment/investment-interest/investment-interest.component.html index 94a3060bf..71a0396ff 100644 --- a/src/app/investment/investment-interest/investment-interest.component.html +++ b/src/app/investment/investment-interest/investment-interest.component.html @@ -12,20 +12,33 @@
TREASURY · INVESTMENT · INTEREST

Investment Interest

- FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD - registers. + Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the + Government Bond Interest, FD Interest and FD registers.

+
+ +
+ +
+ + @if (activeTab === 'receivable') { +
+ + + + + + + + + + + + + + + @for (h of receivableRows; track h.certNo) { + + + + + + + + + + } @empty { + + + + } + + + + + + + +
Cert NoScript / PartyTypeStatusInterest PostedInterest ReceivedReceivable Balance
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ posted(h) | number: '1.2-2' }}₹{{ received(h) | number: '1.2-2' }}₹{{ receivableBalance(h) | number: '1.2-2' }}
No outstanding interest receivable.
Total Receivable₹{{ totalReceivable | number: '1.2-2' }}
+
+ + @if (suspenseRows.length > 0) { +
+ +

+ These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms, + interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely + on actual cash receipt via the Interest Receive tab. +

+ + + + + + + + + + + + + @for (h of suspenseRows; track h.certNo) { + + + + + + + + + } + + + + + + + +
Cert NoScript / PartyTypeStatusProvision RequiredInterest in Suspense
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ provisionRequired(h) | number: '1.2-2' }}₹{{ suspense(h) | number: '1.2-2' }}
Total Suspense₹{{ totalSuspense | number: '1.2-2' }}
+
+ } + } + @if (activeTab === 'fdCalc') {
- +
- +
@@ -125,13 +242,19 @@
+ @if (fdCalcExcludedNpi.length > 0) { +

+ {{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on + accrual basis (see Interest Receivable tab for suspense amount). +

+ }
} @if (activeTab === 'bondCalc') {
- +
- +
@@ -204,6 +329,100 @@
+ @if (bondCalcExcludedNpi.length > 0) { +

+ {{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on + accrual basis (see Interest Receivable tab for suspense amount). +

+ } +
+ } + + + @if (activeTab === 'receive') { +
+ + + + + + + + + + + + + + + @for (h of receiveTargets; track h.certNo) { + + + + + + + + + + } @empty { + + + + } + +
Cert NoScript / PartyTypeStatusOutstandingAmount to Receive
+ {{ h.certNo }} + {{ h.scriptName || h.party }}{{ instrumentLabel(h.instrumentType) }} + {{ provisionLabel(h) }} + ₹{{ outstandingFor(h) | number: '1.2-2' }} + + + +
No interest outstanding for collection.
+
+ +
+ + + + + + + + + + + + + @for (t of receivedLog; track t.voucherNo) { + + + + + + + + } @empty { + + + + } + + + + + + + +
DateVoucher NoCert NoParticularsAmount Received
{{ t.txnDate }} + {{ t.voucherNo }} + {{ t.certNo }}{{ t.particulars }}₹{{ t.amount | number: '1.2-2' }}
No interest received yet.
Total Received₹{{ receivedLogTotal | number: '1.2-2' }}
} diff --git a/src/app/investment/investment-interest/investment-interest.component.ts b/src/app/investment/investment-interest/investment-interest.component.ts index 8910706cb..4a90fcff5 100644 --- a/src/app/investment/investment-interest/investment-interest.component.ts +++ b/src/app/investment/investment-interest/investment-interest.component.ts @@ -19,22 +19,43 @@ import { InvestmentHolding, InvestmentTxn, InstrumentType, + InvestmentProvisionStatus, INSTRUMENT_LABELS, + PROVISION_STATUS_LABELS, accruedInterest, daysToMaturity, maturityAmount, - todayDDMMYYYY + todayDDMMYYYY, + isNpi, + canAccrueInterest, + interestToPost, + interestReceivableBalance, + suspenseInterest, + provisionRequiredInv } from '../investment-data'; +import { exportWorkbook, ExportSheetSpec } from '../investment-export'; -type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister'; +type InterestTab = + | 'receivable' + | 'fdCalc' + | 'bondCalc' + | 'receive' + | 'bondIntRegister' + | 'fdIntRegister' + | 'fdRegister'; /** - * The legacy "Interest" top-menu for Investment — 5 screens: - * 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs), - * 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings). + * The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest + * Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive, + * 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register. * * Interest figures are derived from the same holdings used by the Entry screens and Reports, * so a register here always agrees with what a Purchase/Interest entry recorded. + * + * Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have + * interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash + * receipts are recognised for it, straight through Interest Receive, matching RBI's income + * recognition norms for Non-Performing Investments. */ @Component({ selector: 'mifosx-investment-interest', @@ -50,7 +71,7 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | ] }) export class InvestmentInterestComponent { - activeTab: InterestTab = 'fdCalc'; + activeTab: InterestTab = 'receivable'; asOnDate = todayDDMMYYYY(); fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS]; @@ -63,6 +84,10 @@ export class InvestmentInterestComponent { readonly scriptOptions = SCRIPT_MASTER; + /** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */ + receiveDrafts: Record = {}; + + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -103,18 +128,63 @@ export class InvestmentInterestComponent { return daysToMaturity(h, this.asOnDate); } + get allInterestHoldings(): InvestmentHolding[] { + return [ + ...this.fdHoldings, + ...this.bondHoldings + ].filter((h) => h.status === 'ACTIVE'); + } + + isNpiHolding(h: InvestmentHolding): boolean { + return isNpi(h); + } + + provisionLabel(h: InvestmentHolding): string { + return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD']; + } + + provisionBadgeClass(h: InvestmentHolding): string { + const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD'; + return `inv-badge inv-badge--${status.toLowerCase()}`; + } + + posted(h: InvestmentHolding): number { + return h.interestPosted ?? 0; + } + + received(h: InvestmentHolding): number { + return h.interestReceived ?? 0; + } + + receivableBalance(h: InvestmentHolding): number { + return interestReceivableBalance(h); + } + + suspense(h: InvestmentHolding): number { + return suspenseInterest(h, this.asOnDate); + } + + provisionRequired(h: InvestmentHolding): number { + return provisionRequiredInv(h); + } + // -------------------- 01 FD Interest Calculation -------------------- get fdCalcTargets(): InvestmentHolding[] { + const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.fdCalcAll) { - return this.fdHoldings.filter((h) => h.status === 'ACTIVE'); + return base; } - return this.fdHoldings.filter( - (h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo) - ); + return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo); + } + + /** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis + * only, via the Interest Receive tab. */ + get fdCalcExcludedNpi(): InvestmentHolding[] { + return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); } fdInterest(h: InvestmentHolding): number { - return accruedInterest(h, this.fdCalcDate || this.asOnDate); + return interestToPost(h, this.fdCalcDate || this.asOnDate); } get fdCalcTotal(): number { @@ -122,36 +192,39 @@ export class InvestmentInterestComponent { } processFdInterest(): void { - const targets = this.fdCalcTargets; + const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0); if (targets.length === 0) { - this.toast('No FD holdings match the selected filters.'); + this.toast('No FD holdings have interest due to be posted.'); return; } - const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'INTEREST', - voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, - txnDate: this.fdCalcDate || this.asOnDate, - amount: this.fdInterest(h), - particulars: `FD interest calculated - ${h.party}` - })); + const newTxns: InvestmentTxn[] = targets.map((h, i) => { + const amt = this.fdInterest(h); + h.interestPosted = this.posted(h) + amt; + return { + certNo: h.certNo, + instrumentType: 'FD', + txnType: 'INTEREST', + voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, + txnDate: this.fdCalcDate || this.asOnDate, + amount: amt, + particulars: `FD interest posted (accrual) - ${h.party}` + }; + }); + const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; - this.toast( - `FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.` - ); + this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`); } // -------------------- 02 Govt./Bond Int. Calculation -------------------- get bondCalcTargets(): InvestmentHolding[] { + const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h)); if (this.bondCalcAll) { - return this.bondHoldings.filter((h) => h.status === 'ACTIVE'); + return base; } - return this.bondHoldings.filter((h) => { - if (h.status !== 'ACTIVE') return false; + return base.filter((h) => { if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false; if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false; if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false; @@ -159,8 +232,14 @@ export class InvestmentInterestComponent { }); } + /** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash + * basis only, via the Interest Receive tab. */ + get bondCalcExcludedNpi(): InvestmentHolding[] { + return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h)); + } + bondInterest(h: InvestmentHolding): number { - return accruedInterest(h, this.bondCalcDate || this.asOnDate); + return interestToPost(h, this.bondCalcDate || this.asOnDate); } get bondCalcTotal(): number { @@ -168,29 +247,106 @@ export class InvestmentInterestComponent { } processBondInterest(): void { - const targets = this.bondCalcTargets; + const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0); if (targets.length === 0) { - this.toast('No Govt/Bond holdings match the selected filters.'); + this.toast('No Govt/Bond holdings have interest due to be posted.'); return; } - const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ - certNo: h.certNo, - instrumentType: h.instrumentType, - txnType: 'INTEREST', - voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, - txnDate: this.bondCalcDate || this.asOnDate, - amount: this.bondInterest(h), - particulars: `Coupon/interest calculated - ${h.scriptName}` - })); + const newTxns: InvestmentTxn[] = targets.map((h, i) => { + const amt = this.bondInterest(h); + h.interestPosted = this.posted(h) + amt; + return { + certNo: h.certNo, + instrumentType: h.instrumentType, + txnType: 'INTEREST', + voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, + txnDate: this.bondCalcDate || this.asOnDate, + amount: amt, + particulars: `Coupon/interest posted (accrual) - ${h.scriptName}` + }; + }); + const total = newTxns.reduce((s, t) => s + t.amount, 0); this.txns = [ ...newTxns, ...this.txns ]; - this.toast( - `Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.` + this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); + } + + // -------------------- Interest Receivable (all interest-bearing types) -------------------- + /** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable" + * GL asset. */ + get receivableRows(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0); + } + + /** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown + * separately so the treasury desk can see what cash-basis income is outstanding. */ + get suspenseRows(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0); + } + + get totalReceivable(): number { + return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0); + } + + get totalSuspense(): number { + return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0); + } + + // -------------------- Interest Receive (cash receipt, all interest-bearing types) -------------------- + /** Everything with money outstanding to collect — Standard holdings against their posted + * receivable, and NPI holdings against their notional suspense (cash-basis recognition). */ + get receiveTargets(): InvestmentHolding[] { + return this.allInterestHoldings.filter((h) => + canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0 ); } + outstandingFor(h: InvestmentHolding): number { + return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h); + } + + get receivedLog(): InvestmentTxn[] { + return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED'); + } + + get receivedLogTotal(): number { + return this.receivedLog.reduce((s, t) => s + t.amount, 0); + } + + receiveInterest(h: InvestmentHolding): void { + const draft = this.receiveDrafts[h.certNo]; + if (!draft || draft <= 0) { + this.toast('Enter a valid amount to receive.'); + return; + } + let amt = draft; + let particulars: string; + if (canAccrueInterest(h)) { + amt = Math.min(draft, this.receivableBalance(h)); + h.interestReceived = this.received(h) + amt; + particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`; + } else { + h.interestReceived = this.received(h) + amt; + particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`; + } + this.txns = [ + { + certNo: h.certNo, + instrumentType: h.instrumentType, + txnType: 'INTEREST_RECEIVED', + voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`, + txnDate: this.asOnDate, + amount: amt, + particulars + }, + ...this.txns + ]; + this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`); + delete this.receiveDrafts[h.certNo]; + } + // -------------------- 03 Government Bond Int Register -------------------- get bondIntRegister(): InvestmentTxn[] { return this.txns.filter( @@ -236,6 +392,297 @@ export class InvestmentInterestComponent { return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0); } + // -------------------- Export to Excel -------------------- + private txnExportRow(t: InvestmentTxn): (string | number | null)[] { + return [ + t.txnDate, + t.voucherNo, + t.certNo, + t.instrumentType, + t.particulars, + t.amount + ]; + } + + private exportSheets(): ExportSheetSpec[] { + const subtitle = `As on ${this.asOnDate}`; + const txnHeaders = [ + 'Date', + 'Voucher No', + 'Cert No', + 'Type', + 'Particulars', + 'Amount' + ]; + const txnWidths = [ + 14, + 22, + 14, + 14, + 42, + 16 + ]; + const one = ( + sheetName: string, + title: string, + headers: string[], + widths: number[], + rows: (string | number | null)[][] + ): ExportSheetSpec[] => [ + { sheetName, title, subtitle, headers, widths, rows }]; + + switch (this.activeTab) { + case 'receivable': { + const sheets: ExportSheetSpec[] = [ + { + sheetName: 'Interest Receivable', + title: 'Interest Receivable — Posted but Not Yet Collected', + subtitle, + headers: [ + 'Cert No', + 'Script / Party', + 'Type', + 'Status', + 'Interest Posted', + 'Interest Received', + 'Receivable Balance' + ], + widths: [ + 14, + 30, + 16, + 16, + 16, + 16, + 18 + ], + rows: this.receivableRows.map((h) => [ + h.certNo, + h.scriptName || h.party, + this.instrumentLabel(h.instrumentType), + this.provisionLabel(h), + this.posted(h), + this.received(h), + this.receivableBalance(h) + ]) + } + ]; + if (this.suspenseRows.length > 0) { + sheets.push({ + sheetName: 'NPI Suspense', + title: 'Non-Performing Investments — Interest Held in Suspense', + subtitle, + headers: [ + 'Cert No', + 'Script / Party', + 'Type', + 'Status', + 'Provision Required', + 'Interest in Suspense' + ], + widths: [ + 14, + 30, + 16, + 16, + 16, + 18 + ], + rows: this.suspenseRows.map((h) => [ + h.certNo, + h.scriptName || h.party, + this.instrumentLabel(h.instrumentType), + this.provisionLabel(h), + this.provisionRequired(h), + this.suspense(h) + ]) + }); + } + return sheets; + } + case 'fdCalc': + return one( + 'FD Interest Calc', + 'FD Interest Calculation & Post', + [ + 'Cert No', + 'Party', + 'Face Value', + 'Rate', + 'Frequency', + 'Interest' + ], + [ + 14, + 30, + 16, + 10, + 14, + 16 + ], + this.fdCalcTargets.map((h) => [ + h.certNo, + h.party, + h.faceValue, + h.interestRate, + h.interestFreq, + this.fdInterest(h) + ]) + ); + case 'bondCalc': + return one( + 'Bond Interest Calc', + 'Govt./Bond Interest Calculation & Post', + [ + 'Cert No', + 'Script', + 'Type', + 'Face Value', + 'Rate', + 'Frequency', + 'Interest' + ], + [ + 14, + 30, + 16, + 16, + 10, + 14, + 16 + ], + this.bondCalcTargets.map((h) => [ + h.certNo, + h.scriptName, + this.instrumentLabel(h.instrumentType), + h.faceValue, + h.interestRate, + h.interestFreq, + this.bondInterest(h) + ]) + ); + case 'receive': { + const sheets: ExportSheetSpec[] = [ + { + sheetName: 'Receive Targets', + title: 'Interest Receive — Outstanding for Collection', + subtitle, + headers: [ + 'Cert No', + 'Script / Party', + 'Type', + 'Status', + 'Outstanding' + ], + widths: [ + 14, + 30, + 16, + 16, + 16 + ], + rows: this.receiveTargets.map((h) => [ + h.certNo, + h.scriptName || h.party, + this.instrumentLabel(h.instrumentType), + this.provisionLabel(h), + this.outstandingFor(h) + ]) + } + ]; + if (this.receivedLog.length > 0) { + sheets.push({ + sheetName: 'Received Log', + title: 'Interest Received Log', + subtitle, + headers: txnHeaders, + widths: txnWidths, + rows: this.receivedLog.map((t) => this.txnExportRow(t)) + }); + } + return sheets; + } + case 'bondIntRegister': + return one( + 'Govt Bond Int Register', + 'Government Bond Interest Register', + txnHeaders, + txnWidths, + this.bondIntRegister.map((t) => this.txnExportRow(t)) + ); + case 'fdIntRegister': + return one( + 'FD Interest Register', + 'FD Interest Register', + txnHeaders, + txnWidths, + this.fdIntRegister.map((t) => this.txnExportRow(t)) + ); + case 'fdRegister': + return one( + 'FD Register', + 'FD Register', + [ + 'Cert No', + 'Party', + 'Sub Type', + 'Face Value', + 'Rate', + 'Frequency', + 'Deposit Date', + 'Maturity Date', + 'Days Left', + 'Accrued Interest', + 'Maturity Value', + 'Status' + ], + [ + 14, + 26, + 16, + 16, + 10, + 14, + 14, + 14, + 12, + 16, + 16, + 12 + ], + this.fdRegister.map((h) => [ + h.certNo, + h.party, + h.subType, + h.faceValue, + h.interestRate, + h.interestFreq, + h.purchaseDate, + h.maturityDate, + this.daysLeft(h), + this.accrued(h), + this.maturity(h), + h.status + ]) + ); + default: + return one('Interest', 'Investment Interest', [], [], []); + } + } + + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook(this.exportSheets(), `Investment-Interest-${this.activeTab}-${this.asOnDate}`); + this.toast('Exported.'); + } catch (err) { + console.error('Investment Interest export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index d44cbf788..6c62c4d0d 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -16,6 +16,11 @@ Market Detail masters.

+
+ +
diff --git a/src/app/investment/investment-masters/investment-masters.component.ts b/src/app/investment/investment-masters/investment-masters.component.ts index de324cc4d..ee99159a5 100644 --- a/src/app/investment/investment-masters/investment-masters.component.ts +++ b/src/app/investment/investment-masters/investment-masters.component.ts @@ -32,6 +32,7 @@ import { LienReason, InstrumentType } from '../investment-data'; +import { exportWorkbook, ExportSheetSpec } from '../investment-export'; type MasterTab = | 'party' @@ -85,6 +86,20 @@ export class InvestmentMastersComponent { 'SHARE' ]; + private readonly masterLabels: Record = { + party: 'Bank / Party Master', + script: 'Script Master', + subtype: 'SubType Master', + broker: 'Broker Master', + control: 'Control Parameter', + dailyscript: 'Daily Script Master', + lien: 'LIEN Reason Master', + mfproduct: 'Mutual Fund Product Master', + sglbank: 'SGL Bank Master', + marketdetail: 'Script Market Detail' + }; + + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -251,6 +266,244 @@ export class InvestmentMastersComponent { this.toast('Daily script rate added.'); } + // -------------------- Export to Excel -------------------- + private exportSheets(): ExportSheetSpec[] { + const title = this.masterLabels[this.activeTab]; + const one = (headers: string[], widths: number[], rows: (string | number | null)[][]): ExportSheetSpec[] => [ + { sheetName: title, title, headers, widths, rows }]; + + switch (this.activeTab) { + case 'party': + return one( + [ + 'Party Code', + 'Party Name', + 'Type', + 'PAN No' + ], + [ + 12, + 30, + 16, + 14 + ], + this.parties.map((p) => [ + p.partyCode, + p.partyName, + p.partyType, + p.panNo + ]) + ); + case 'script': + return one( + [ + 'Script Code', + 'Script Name', + 'Instrument', + 'ISIN', + 'Face Value/Unit' + ], + [ + 16, + 30, + 16, + 16, + 16 + ], + this.scripts.map((s) => [ + s.scriptCode, + s.scriptName, + s.instrumentType, + s.isin, + s.faceValuePerUnit + ]) + ); + case 'subtype': + return one( + [ + 'Code', + 'Sub Type', + 'Instrument' + ], + [ + 10, + 24, + 16 + ], + this.subTypes.map((s) => [ + s.subTypeCode, + s.subTypeName, + s.instrumentType + ]) + ); + case 'broker': + return one( + [ + 'Broker Code', + 'Broker Name', + 'Contact', + 'Empanelled On', + 'Status' + ], + [ + 14, + 30, + 16, + 16, + 12 + ], + this.brokers.map((b) => [ + b.brokerCode, + b.brokerName, + b.contactNo, + b.empanelledOn, + b.status + ]) + ); + case 'control': + return one( + [ + 'Parameter', + 'Value', + 'Description' + ], + [ + 26, + 14, + 40 + ], + this.controlParams.map((c) => [ + c.paramName, + c.paramValue, + c.description + ]) + ); + case 'dailyscript': + return one( + [ + 'Rate Date', + 'Script Code', + 'Script Name', + 'Open', + 'High', + 'Low', + 'Close' + ], + [ + 14, + 16, + 26, + 12, + 12, + 12, + 12 + ], + this.dailyScriptRates.map((r) => [ + r.rateDate, + r.scriptCode, + r.scriptName, + r.openRate, + r.highRate, + r.lowRate, + r.closeRate + ]) + ); + case 'lien': + return one( + [ + 'Reason Code', + 'Description' + ], + [ + 14, + 40 + ], + this.lienReasons.map((l) => [ + l.reasonCode, + l.reasonDescription + ]) + ); + case 'mfproduct': + return one( + [ + 'Product Code', + 'Product Name', + 'AMC', + 'Category', + 'Risk' + ], + [ + 14, + 30, + 24, + 20, + 12 + ], + this.mfProducts.map((m) => [ + m.productCode, + m.productName, + m.amcName, + m.category, + m.riskLevel + ]) + ); + case 'sglbank': + return one( + [ + 'SGL Code', + 'Bank Name', + 'Branch' + ], + [ + 14, + 30, + 24 + ], + this.sglBanks.map((s) => [ + s.sglCode, + s.bankName, + s.branch + ]) + ); + case 'marketdetail': + return one( + [ + 'Script Code', + 'Market Price', + 'As On', + 'YTM %' + ], + [ + 16, + 16, + 14, + 12 + ], + this.marketDetails.map((m) => [ + m.scriptCode, + m.marketPrice, + m.asOnDate, + m.ytm + ]) + ); + default: + return one([], [], []); + } + } + + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook(this.exportSheets(), `Investment-${this.masterLabels[this.activeTab]}`); + this.toast(`${this.masterLabels[this.activeTab]} exported.`); + } catch (err) { + console.error('Investment Masters export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/investment/investment-reports/investment-reports.component.html b/src/app/investment/investment-reports/investment-reports.component.html index cae27e4a5..f8c91431b 100644 --- a/src/app/investment/investment-reports/investment-reports.component.html +++ b/src/app/investment/investment-reports/investment-reports.component.html @@ -16,6 +16,11 @@ same holdings/transactions used by the Entry screens.

+
+ +
@@ -692,3 +697,5 @@
} + +
{{ toastMsg }}
diff --git a/src/app/investment/investment-reports/investment-reports.component.ts b/src/app/investment/investment-reports/investment-reports.component.ts index 551a5423a..51eddea92 100644 --- a/src/app/investment/investment-reports/investment-reports.component.ts +++ b/src/app/investment/investment-reports/investment-reports.component.ts @@ -27,6 +27,7 @@ import { daysToMaturity, todayDDMMYYYY } from '../investment-data'; +import { exportWorkbook, ExportSheetSpec } from '../investment-export'; type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio'; @@ -50,6 +51,11 @@ export class InvestmentReportsComponent { activeReport = 'govtSecBalance'; asOnDate = todayDDMMYYYY(); + generating = false; + toastMsg = ''; + toastVisible = false; + private toastTimer: ReturnType | undefined; + setGroup(group: ReportGroup, defaultReport: string): void { this.activeGroup = group; this.activeReport = defaultReport; @@ -244,4 +250,364 @@ export class InvestmentReportsComponent { get portfolioTotalMaturity(): number { return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0); } + + // -------------------- Export to Excel -------------------- + private readonly holdingsExportHeaders = [ + 'Cert No', + 'Script / Party', + 'Sub Type', + 'Units', + 'Face Value', + 'Purchase Rate', + 'Interest Rate', + 'Purchase Date', + 'Maturity Date', + 'Accrued Interest', + 'Maturity Value', + 'Status' + ]; + private readonly holdingsExportWidths = [ + 14, + 30, + 16, + 12, + 16, + 14, + 12, + 14, + 14, + 16, + 16, + 12 + ]; + private holdingsExportRow(h: InvestmentHolding): (string | number | null)[] { + return [ + h.certNo, + h.scriptName || h.party, + h.subType, + h.units, + h.faceValue, + h.purchaseRate, + h.interestRate, + h.purchaseDate, + h.maturityDate, + this.accrued(h), + this.maturity(h), + h.status + ]; + } + + private readonly txnExportHeaders = [ + 'Date', + 'Voucher No', + 'Cert No', + 'Type', + 'Particulars', + 'Amount' + ]; + private readonly txnExportWidths = [ + 14, + 22, + 14, + 14, + 42, + 16 + ]; + private txnExportRow(t: InvestmentTxn): (string | number | null)[] { + return [ + t.txnDate, + t.voucherNo, + t.certNo, + t.txnType, + t.particulars, + t.amount + ]; + } + + private exportSheets(): ExportSheetSpec[] { + const subtitle = `As on ${this.asOnDate}`; + const one = ( + sheetName: string, + title: string, + headers: string[], + widths: number[], + rows: (string | number | null)[][] + ): ExportSheetSpec[] => [ + { sheetName, title, subtitle, headers, widths, rows }]; + + switch (this.activeReport) { + case 'govtSecBalance': + return one( + 'Govt Sec Balance', + 'Govt Security Balance Report', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.govtSecHoldings.map((h) => this.holdingsExportRow(h)) + ); + case 'shareBalance': + return one( + 'Share Balance', + 'Share Balance Report', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.shareHoldings.map((h) => this.holdingsExportRow(h)) + ); + case 'fdrBalance': + return one( + 'FDR Balance', + 'FDR Balance Report', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.fdHoldings.map((h) => this.holdingsExportRow(h)) + ); + case 'bankInvList': + return one( + 'Bank Investment List', + 'Bank Investment List', + [ + 'Party', + 'Holdings', + 'Total Face Value' + ], + [ + 30, + 14, + 18 + ], + this.bankInvestmentList.map((r) => [ + r.party, + r.count, + r.totalFaceValue + ]) + ); + case 'fdLedger': + return one( + 'FD Ledger', + 'FD Ledger', + this.txnExportHeaders, + this.txnExportWidths, + this.fdTxns.map((t) => this.txnExportRow(t)) + ); + case 'bondLedger': + return one( + 'Govt-Bond Ledger', + 'Govt/Bond Ledger', + this.txnExportHeaders, + this.txnExportWidths, + this.bondTxns.map((t) => this.txnExportRow(t)) + ); + case 'shareLedger': + return one( + 'Share Ledger', + 'Share Ledger', + this.txnExportHeaders, + this.txnExportWidths, + this.shareTxns.map((t) => this.txnExportRow(t)) + ); + case 'amortizeLedger': + return one( + 'Amortize Ledger', + 'Amortize Ledger', + this.txnExportHeaders, + this.txnExportWidths, + this.amortizeTxns.map((t) => this.txnExportRow(t)) + ); + case 'dealSlip': + return one( + 'Deal Slip', + 'Deal Slip Register', + this.txnExportHeaders, + this.txnExportWidths, + this.dealSlips.map((t) => this.txnExportRow(t)) + ); + case 'intDueRegister': + return one( + 'Interest Due Register', + 'Interest Due Register (Non-Cumulative Holdings)', + [ + ...this.holdingsExportHeaders, + 'Due Amount' + ], + [ + ...this.holdingsExportWidths, + 16 + ], + this.interestDueRegister.map((h) => [ + ...this.holdingsExportRow(h), + h.dueAmount + ]) + ); + case 'slrRegister': + return one( + 'SLR Bond Register', + 'SLR Bond Register', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.slrBondRegister.map((h) => this.holdingsExportRow(h)) + ); + case 'pendingReceived': + return [ + { + sheetName: 'Received Interest', + title: 'Received Interest / Dividend', + subtitle, + headers: this.txnExportHeaders, + widths: this.txnExportWidths, + rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t)) + }, + { + sheetName: 'Pending Interest', + title: 'Pending Interest (Not Yet Posted)', + subtitle, + headers: this.holdingsExportHeaders, + widths: this.holdingsExportWidths, + rows: this.pendingInterestHoldings.map((h) => this.holdingsExportRow(h)) + } + ]; + case 'maturedFd': + return one( + 'Matured FD', + 'List of Matured FD', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.maturedFdList.map((h) => this.holdingsExportRow(h)) + ); + case 'maturingList': + return one( + 'Maturity List', + 'Govt Security / FD Maturity List', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.maturingList.map((h) => this.holdingsExportRow(h)) + ); + case 'maturityPattern': + return one( + 'Maturity Pattern', + 'Maturity Pattern Register', + [ + 'Bucket', + 'Holdings', + 'Face Value' + ], + [ + 22, + 14, + 18 + ], + this.maturityBuckets.map((b) => [ + b.label, + b.count, + b.faceValue + ]) + ); + case 'govtSecList': + return one( + 'Govt Security List', + 'Govt Security List', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.govtSecList.map((h) => this.holdingsExportRow(h)) + ); + case 'fdSecList': + return one( + 'FD Security List', + 'FD Security List', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.fdSecList.map((h) => this.holdingsExportRow(h)) + ); + case 'shareSecList': + return one( + 'Share Security List', + 'Share Security List', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.shareSecList.map((h) => this.holdingsExportRow(h)) + ); + case 'mfList': + return one( + 'Mutual Fund List', + 'Mutual Fund List', + this.holdingsExportHeaders, + this.holdingsExportWidths, + this.mutualFundList.map((h) => this.holdingsExportRow(h)) + ); + case 'shiftingReport': + return one( + 'Shifting Report', + 'Shifting Report — SLR Market Deviation ≥ 1%', + [ + 'Cert No', + 'Script', + 'Purchase Rate', + 'Market Price', + 'Deviation %' + ], + [ + 14, + 30, + 16, + 16, + 14 + ], + this.shiftingCandidates.map((x) => [ + x.certNo, + x.scriptName, + x.purchaseRate, + x.marketPrice, + x.deviationPct + ]) + ); + case 'portfolio': + return one( + 'Portfolio', + 'Investment Portfolio — Consolidated Summary', + [ + 'Instrument Type', + 'Holdings', + 'Face Value', + 'Accrued Interest', + 'Maturity Value' + ], + [ + 24, + 12, + 18, + 18, + 18 + ], + this.portfolioSummary.map((p) => [ + p.label, + p.count, + p.faceValue, + p.accrued, + p.maturityValue + ]) + ); + default: + return one('Report', 'Investment Report', this.holdingsExportHeaders, this.holdingsExportWidths, []); + } + } + + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook(this.exportSheets(), `Investment-${this.activeReport}-${this.asOnDate}`); + this.toast('Report exported.'); + } catch (err) { + console.error('Investment report export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + + private toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + if (this.toastTimer) { + clearTimeout(this.toastTimer); + } + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } } diff --git a/src/app/investment/investment-shared.scss b/src/app/investment/investment-shared.scss index 66f4f2f55..9945581f3 100644 --- a/src/app/investment/investment-shared.scss +++ b/src/app/investment/investment-shared.scss @@ -128,6 +128,32 @@ color: #b91c1c; border: 1px solid #fecaca; } + + // Investment-provisioning classification (Non-Performing Investment) — same severity ramp as + // the NPA module's stage badges, kept local since it colours a different status field here. + &--standard { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } + + &--sub_standard { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &--doubtful { + background: #fdf2f8; + color: #9d174d; + border: 1px solid #fbcfe8; + } + + &--loss { + background: #1f2937; + color: #f9fafb; + border: 1px solid #1f2937; + } } .inv-form-grid { diff --git a/src/app/investment/share-investment/share-investment.component.html b/src/app/investment/share-investment/share-investment.component.html index 7312ec74c..7587fd2ab 100644 --- a/src/app/investment/share-investment/share-investment.component.html +++ b/src/app/investment/share-investment/share-investment.component.html @@ -15,6 +15,13 @@ Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.

+ @if (activeTab === 'holdings') { +
+ +
+ }
diff --git a/src/app/investment/share-investment/share-investment.component.ts b/src/app/investment/share-investment/share-investment.component.ts index f5835ee0d..1ce49d99c 100644 --- a/src/app/investment/share-investment/share-investment.component.ts +++ b/src/app/investment/share-investment/share-investment.component.ts @@ -10,6 +10,7 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data'; +import { exportWorkbook } from '../investment-export'; type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff'; @@ -50,6 +51,7 @@ export class ShareInvestmentComponent { holdings: InvestmentHolding[] = [...SHARE_HOLDINGS]; txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE'); + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -252,6 +254,59 @@ export class ShareInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + this.generating = true; + try { + await exportWorkbook( + [ + { + sheetName: 'Share Holdings', + title: 'Share Holdings Register', + subtitle: `As on ${this.asOnDate}`, + headers: [ + 'Cert No', + 'Script', + 'Party', + 'Units', + 'Purchase Rate', + 'Face Value', + 'Purchase Date', + 'Status' + ], + widths: [ + 14, + 24, + 22, + 12, + 14, + 16, + 14, + 12 + ], + rows: this.holdings.map((h) => [ + h.certNo, + h.scriptName, + h.party, + h.units, + h.purchaseRate, + h.faceValue, + h.purchaseDate, + h.status + ]) + } + ], + `Share-Holdings-${this.asOnDate}` + ); + this.toast('Share Holdings exported.'); + } catch (err) { + console.error('Share Holdings export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/npa/npa-data.ts b/src/app/npa/npa-data.ts index f3e191569..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 @@ -553,14 +817,23 @@ export function provisionHeld(a: LoanAccount): number { return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio)); } -/** Interest income de-recognised because the account has been NPA for longer than 90 days. */ -export function interestReversed(a: LoanAccount): number { - const stage = currentStage(a); - if (!isNpaStage(stage)) { +/** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any + * account that isn't currently NPA. The single source both interest-reversal and NPA-since-date + * are built from, so neither can ever quietly drift out of step with the other. */ +export function daysSinceNpa(a: LoanAccount): number { + if (!isNpaStage(currentStage(a))) { return 0; } - const daysSinceNpa = Math.max(0, a.overdueDays - 90); - return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365)); + return Math.max(0, a.overdueDays - 90); +} + +/** Interest income de-recognised because the account has been NPA for longer than 90 days. */ +export function interestReversed(a: LoanAccount): number { + const days = daysSinceNpa(a); + if (days <= 0) { + return 0; + } + return round(a.outstandingBalance * (a.interestRate / 100) * (days / 365)); } /** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays, @@ -569,14 +842,96 @@ export function npaSinceDate(a: LoanAccount): string { if (!isNpaStage(currentStage(a))) { return '—'; } - const daysSinceNpa = Math.max(0, a.overdueDays - 90); - return fmt(addDays(new Date(), -daysSinceNpa)); + return fmt(addDays(new Date(), -daysSinceNpa(a))); } export function totalRecovered(a: LoanAccount): number { return a.recoveries.reduce((s, r) => s + r.amount, 0); } +/** + * Cash recovered against an NPA account is appropriated to the memorandum "undebited interest" + * bucket first — consistent with IRAC norms recognising NPA interest income only on actual cash + * receipt, never on accrual — and only the balance beyond that reduces principal. Capped at the + * interest actually accrued, so this can never show more interest "recovered" than ever accrued. + */ +export function interestRecoveredCash(a: LoanAccount): number { + return Math.min(totalRecovered(a), interestReversed(a)); +} + +/** Memorandum interest still un-recovered — the "undebited interest" carried outside the + * borrower's loan account (per IRAC norms), less whatever cash has actually been appropriated + * against it so far. */ +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'; @@ -594,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; @@ -608,12 +963,19 @@ export const REPORT_META: ReportMeta[] = [ { id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' }, { id: 'movement', group: 'analysis', label: 'NPA Movement Report' }, { id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' }, + { id: '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 0c75cb956..98aaee3e4 100644 --- a/src/app/npa/npa-reports/npa-reports.component.html +++ b/src/app/npa/npa-reports/npa-reports.component.html @@ -38,6 +38,9 @@ +
@@ -72,7 +75,7 @@
- @for (g of ['reports', 'analysis', 'classification']; track g) { + @for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
@@ -781,7 +789,97 @@
} - + + @if (activeReport === 'undebited-interest') { +
+ +

+ Once an account turns NPA, interest is no longer debited to the borrower's loan account or recognised as income + — it is tracked here as memorandum "undebited interest" and recognised only when actually recovered in cash. + Cash recovered is appropriated to this bucket first; any balance beyond it reduces principal. +

+
+ + + + + + + + + + + + + + + + + @for (a of paged(npaAccounts); track a.accountNo) { + + + + + + + + + + + + + } @empty { + + + + } + + + + + + + + + +
Account NoCustomerBranchStageNPA SinceDays NPARateUndebited Interest AccruedInterest Recovered (Cash)Undebited Interest Balance
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }} + {{ stageLabel(stage(a)) }} + {{ npaSince(a) }}{{ daysSinceNpaOf(a) }}{{ a.interestRate }}%₹{{ interestRev(a) | number: '1.0-0' }}₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}
No NPA accounts in this branch.
Total ({{ npaAccounts.length }} accounts)₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}₹{{ interestRecoveredCashTotal | number: '1.0-0' }}₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}
+
+ @if (npaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(npaAccounts) }} + +
+
+ } +
+ } + + @if (activeReport === 'probable') {
} - + @if (activeReport === 'sma') {
@@ -944,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 5d6eb24e5..6bf33e1f5 100644 --- a/src/app/npa/npa-reports/npa-reports.component.ts +++ b/src/app/npa/npa-reports/npa-reports.component.ts @@ -10,8 +10,10 @@ 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'; import { LOAN_ACCOUNTS, BRANCHES, @@ -35,9 +37,23 @@ import { movementLabel, stageBadgeClass, npaSinceDate, + daysSinceNpa, + interestRecoveredCash, + 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 { @@ -68,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', @@ -86,7 +118,8 @@ interface RecoveryRow { imports: [ CommonModule, FormsModule, - RouterModule + RouterModule, + FontAwesomeModule ] }) export class NpaReportsComponent implements OnInit, OnDestroy { @@ -119,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: '', @@ -127,6 +162,9 @@ export class NpaReportsComponent implements OnInit, OnDestroy { mode: 'Cash Deposit' }; + /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */ + generating = false; + toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -264,6 +302,118 @@ export class NpaReportsComponent implements OnInit, OnDestroy { return npaSinceDate(a); } + daysSinceNpaOf(a: LoanAccount): number { + return daysSinceNpa(a); + } + + interestRecoveredCashOf(a: LoanAccount): number { + return interestRecoveredCash(a); + } + + undebitedInterestBalanceOf(a: LoanAccount): number { + 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]; } @@ -355,6 +505,19 @@ export class NpaReportsComponent implements OnInit, OnDestroy { return this.npaAccounts.reduce((s, a) => s + this.recovered(a), 0); } + // -------------------- Undebited Interest Recovery Report -------------------- + get undebitedInterestAccruedTotal(): number { + return this.npaAccounts.reduce((s, a) => s + this.interestRev(a), 0); + } + + get interestRecoveredCashTotal(): number { + return this.npaAccounts.reduce((s, a) => s + this.interestRecoveredCashOf(a), 0); + } + + get undebitedInterestBalanceTotal(): number { + return this.npaAccounts.reduce((s, a) => s + this.undebitedInterestBalanceOf(a), 0); + } + get restructuredAccounts(): LoanAccount[] { return this.allAccounts.filter((a) => a.isRestructured); } @@ -363,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) => { @@ -439,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.'); @@ -479,6 +732,946 @@ export class NpaReportsComponent implements OnInit, OnDestroy { this.toast(`${this.reportLabel(this.activeReport)} generated as on ${this.asOnDate}.`); } + // -------------------- Export to Excel (whichever report is currently on screen) -------------------- + async exportExcel(): Promise { + this.generating = true; + try { + const sheets = this.buildExportSheets(); + await exportWorkbook(sheets, `${this.reportLabel(this.activeReport)}-${this.scopeName}-${this.asOnDate}`); + } catch (err) { + console.error('NPA export failed', err); + } finally { + this.generating = false; + } + } + + /** Builds the workbook for whichever report is active — every sheet is built from the same + * derived getters the on-screen tables use, so the export can never disagree with the screen. */ + private buildExportSheets(): ExportSheetSpec[] { + const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`; + + switch (this.activeReport) { + case 'interest': + return [ + { + sheetName: 'NPA Interest', + title: 'NPA Interest — Income Reversed on Non-Performing Accounts', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Product', + 'Stage', + 'Outstanding', + 'Rate %', + 'Overdue Days', + 'Interest Reversed' + ], + widths: [ + 16, + 22, + 16, + 16, + 14, + 16, + 10, + 12, + 18 + ], + rows: [ + ...this.npaAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + a.productName, + this.stageLabel(this.stage(a)), + a.outstandingBalance, + a.interestRate, + a.overdueDays, + this.interestRev(a) + ]), + [ + `Total (${this.npaAccounts.length} accounts)`, + '', + '', + '', + '', + '', + '', + '', + this.sumInterest(this.npaAccounts) + ] + ] + } + ]; + + case 'status': + return [ + { + sheetName: 'NPA Status', + title: 'NPA Status Report — Full Non-Performing Book', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Product', + 'Secured', + 'Outstanding', + 'Overdue Amount', + 'Overdue Days', + 'NPA Since', + 'Stage', + 'Restructured', + 'Legal Action', + 'Provision Required' + ], + widths: [ + 16, + 22, + 16, + 16, + 10, + 16, + 16, + 12, + 12, + 14, + 12, + 22, + 18 + ], + rows: this.npaAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + a.productName, + a.secured ? 'Yes' : 'No', + a.outstandingBalance, + a.overdueAmount, + a.overdueDays, + this.npaSince(a), + this.stageLabel(this.stage(a)), + a.isRestructured ? 'Yes' : 'No', + this.legalActionLabel(a.legalActionStatus), + this.provisionReq(a) + ]) + } + ]; + + case 'recovery': + return [ + { + sheetName: 'NPA Recovery', + title: 'NPA Recovery Register', + subtitle, + headers: [ + 'Date', + 'Account No', + 'Customer', + 'Branch', + 'Mode', + 'Recovered Amount' + ], + widths: [ + 12, + 16, + 22, + 16, + 18, + 18 + ], + rows: [ + ...this.recoveryRows.map((r) => [ + r.date, + r.accountNo, + r.customerName, + r.branchName, + r.mode, + r.amount + ]), + [ + 'Total Recovered', + '', + '', + '', + '', + this.totalRecoveredAmount + ] + ] + } + ]; + + case 'summary': { + const sheets: ExportSheetSpec[] = [ + { + sheetName: 'Stage Summary', + title: 'NPA Summary Report — Stage-wise', + subtitle, + headers: [ + 'Classification', + 'Accounts', + 'Outstanding', + 'Provision Required' + ], + widths: [ + 22, + 12, + 18, + 18 + ], + rows: [ + ...this.stageSummary.map((row) => [ + row.label, + row.count, + row.outstanding, + row.provisionReq + ]), + [ + 'Total', + this.grandTotalAccounts, + this.grandTotalOutstanding, + this.grandTotalProvision + ] + ] + } + ]; + if (this.isHeadOffice && !this.branchFilter) { + sheets.push({ + sheetName: 'Branch Summary', + title: 'Branch-wise NPA Summary — All Branches', + subtitle: `As On: ${this.asOnDate}`, + headers: [ + 'Branch', + 'Total Accounts', + 'Total Advances', + 'NPA Accounts', + 'GNPA Outstanding', + 'Provision Required', + 'GNPA %' + ], + widths: [ + 20, + 14, + 18, + 14, + 18, + 18, + 10 + ], + rows: this.branchSummary.map((row) => [ + row.branchName, + row.totalAccounts, + row.totalAdvances, + row.npaCount, + row.npaOutstanding, + row.provisionReq, + Number(row.gnpaRatio.toFixed(2)) + ]) + }); + } + return sheets; + } + + case 'account-detail': { + const a = this.selectedAccount; + const detailRows: (string | number | null)[][] = a + ? [ + [ + 'Account No', + a.accountNo + ], + [ + 'Customer', + a.customerName + ], + [ + 'Branch', + a.branchName + ], + [ + 'Product', + a.productName + ], + [ + 'Secured', + a.secured ? 'Yes' : 'No' + ], + [ + 'Guarantor', + a.guarantorName + ], + [ + 'Sanctioned Amount', + a.sanctionedAmount + ], + [ + 'Disbursement Date', + a.disbursementDate + ], + [ + 'Interest Rate %', + a.interestRate + ], + [ + 'Outstanding Balance', + a.outstandingBalance + ], + [ + 'Overdue Amount', + a.overdueAmount + ], + [ + 'Overdue Days', + a.overdueDays + ], + [ + 'NPA Since', + this.npaSince(a) + ], + [ + 'Restructured', + a.isRestructured ? 'Yes' : 'No' + ], + [ + 'Last Payment Date', + a.lastPaymentDate + ], + [ + 'Collateral Value', + a.collateralValue + ], + [ + 'Stage', + this.stageLabel(this.stage(a)) + ], + [ + 'Provisioning Rate %', + this.provisionRate(a) + ], + [ + 'Provision Required', + this.provisionReq(a) + ], + [ + 'Provision Held', + this.provisionHeldAmt(a) + ], + [ + 'Interest Reversed', + this.interestRev(a) + ], + [ + 'Total Recovered', + this.recovered(a) + ], + [ + 'Movement (vs Last Quarter)', + this.movement(a) + ], + [ + 'NPA Reason', + a.npaReason + ], + [ + 'Recovery Officer', + a.recoveryOfficer + ], + [ + 'Legal Action Status', + this.legalActionLabel(a.legalActionStatus) + ] + ] + : []; + return [ + { + sheetName: 'Account Detail', + title: a ? `${a.accountNo} — ${a.customerName}` : 'NPA Account Detail Report', + subtitle, + headers: [ + 'Field', + 'Value' + ], + widths: [ + 26, + 32 + ], + rows: detailRows + }, + { + sheetName: 'Recovery History', + title: a ? `Recovery History — ${a.accountNo}` : 'Recovery History', + subtitle, + headers: [ + 'Date', + 'Mode', + 'Amount' + ], + widths: [ + 14, + 20, + 18 + ], + rows: (a?.recoveries || []).map((r) => [ + r.date, + 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 + ]) + } + ]; + } + + case 'movement': + return [ + { + sheetName: 'NPA Movement', + title: 'Classification Movement — Current Quarter vs Previous Quarter', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Previous Stage', + 'Current Stage', + 'Movement', + 'Outstanding' + ], + widths: [ + 16, + 22, + 16, + 16, + 16, + 26, + 16 + ], + rows: this.movedAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + this.stageLabel(this.prevStage(a)), + this.stageLabel(this.stage(a)), + this.movement(a), + a.outstandingBalance + ]) + } + ]; + + case 'provision': + return [ + { + sheetName: 'NPA Provision', + title: 'Provision Held vs Required — Full Loan Book', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Stage', + 'Outstanding', + 'Rate %', + 'Provision Required', + 'Provision Held', + 'Shortfall / (Excess)' + ], + widths: [ + 16, + 22, + 16, + 14, + 16, + 10, + 18, + 16, + 18 + ], + rows: [ + ...this.provisionRows.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + this.stageLabel(this.stage(a)), + a.outstandingBalance, + this.provisionRate(a), + this.provisionReq(a), + this.provisionHeldAmt(a), + this.shortfall(a) + ]), + [ + `Total (${this.provisionRows.length} accounts)`, + '', + '', + '', + '', + '', + this.provisionRequiredTotal, + this.provisionHeldTotal, + this.provisionShortfallTotal + ] + ] + } + ]; + + case 'undebited-interest': + return [ + { + sheetName: 'Undebited Interest', + title: 'Undebited Interest Recovery Report — Memorandum Interest (IRAC Norms)', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Stage', + 'NPA Since', + 'Days NPA', + 'Rate %', + 'Undebited Interest Accrued', + 'Interest Recovered (Cash)', + 'Undebited Interest Balance' + ], + widths: [ + 16, + 22, + 16, + 14, + 12, + 10, + 10, + 22, + 20, + 22 + ], + rows: [ + ...this.npaAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + this.stageLabel(this.stage(a)), + this.npaSince(a), + this.daysSinceNpaOf(a), + a.interestRate, + this.interestRev(a), + this.interestRecoveredCashOf(a), + this.undebitedInterestBalanceOf(a) + ]), + [ + `Total (${this.npaAccounts.length} accounts)`, + '', + '', + '', + '', + '', + '', + this.undebitedInterestAccruedTotal, + this.interestRecoveredCashTotal, + this.undebitedInterestBalanceTotal + ] + ] + } + ]; + + case 'probable': + return [ + { + sheetName: 'Probable NPA', + title: 'Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Product', + 'Outstanding', + 'Overdue Amount', + 'Overdue Days', + 'Last Payment', + 'Follow-up' + ], + widths: [ + 16, + 22, + 16, + 16, + 16, + 16, + 12, + 14, + 12 + ], + rows: [ + ...this.probableNpaAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + a.productName, + a.outstandingBalance, + a.overdueAmount, + a.overdueDays, + a.lastPaymentDate, + a.overdueDays > 75 ? 'Urgent' : 'Monitor' + ]), + [ + `Total (${this.probableNpaAccounts.length} accounts)`, + '', + '', + '', + this.sumOutstanding(this.probableNpaAccounts), + '', + '', + '', + '' + ] + ] + } + ]; + + case 'sma': + return [ + { + sheetName: 'SMA Report', + title: 'Special Mention Accounts — SMA-0 / SMA-1 / SMA-2', + subtitle, + headers: [ + 'Account No', + 'Customer', + 'Branch', + 'Product', + 'Stage', + 'Outstanding', + 'Overdue Amount', + 'Overdue Days' + ], + widths: [ + 16, + 22, + 16, + 16, + 14, + 16, + 16, + 12 + ], + rows: [ + ...this.smaAccounts.map((a) => [ + a.accountNo, + a.customerName, + a.branchName, + a.productName, + this.stageLabel(this.stage(a)), + a.outstandingBalance, + a.overdueAmount, + a.overdueDays + ]), + [ + `Total (${this.smaAccounts.length} accounts)`, + '', + '', + '', + '', + this.sumOutstanding(this.smaAccounts), + this.sumOverdue(this.smaAccounts), + '' + ] + ] + } + ]; + + 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 []; + } + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; diff --git a/src/app/npa/npa-shared.scss b/src/app/npa/npa-shared.scss index 64e3d6cfe..419e908f0 100644 --- a/src/app/npa/npa-shared.scss +++ b/src/app/npa/npa-shared.scss @@ -10,6 +10,10 @@ // and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since // they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific). +.npa-report-note { + max-width: none; +} + .npa-badge { display: inline-flex; align-items: center; @@ -84,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)); diff --git a/src/app/reconciliation/reconciliation-data.ts b/src/app/reconciliation/reconciliation-data.ts new file mode 100644 index 000000000..3a8c8eaec --- /dev/null +++ b/src/app/reconciliation/reconciliation-data.ts @@ -0,0 +1,703 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Dummy data layer for the "Reconciliation" mega-menu — 16 screens across 5 groups (Core & +// Books / Bank & Clearing / Digital & Payments / Instruments & Members / Compliance & +// Financials). Every reconciliation type shares one generic two-sided model (Side A vs Side B, +// difference, match status) so the UI, KPIs and "Mark as Reconciled" action are all driven by +// the same pure functions below — no report can duplicate/contradict another's numbers. +// +// Branches are the SAME 8 used by the NPA module (Branch 1/Branch 2 + 6 named branches) so a +// real bank demo never shows two different branch lists in two different menus. + +import { BRANCHES, BranchMaster } from '../npa/npa-data'; + +export { BRANCHES }; +export type { BranchMaster }; + +export type ReconGroupId = 'core' | 'bank' | 'digital' | 'instruments' | 'compliance'; +export type ReconScope = 'BRANCH' | 'HEAD_OFFICE'; +export type ReconStatus = 'MATCHED' | 'UNMATCHED' | 'PENDING' | 'RESOLVED'; + +export const GROUP_LABELS: Record = { + core: 'Core & Books', + bank: 'Bank & Clearing', + digital: 'Digital & Payments', + instruments: 'Instruments & Members', + compliance: 'Compliance & Financials' +}; + +export interface ReconTypeMeta { + id: string; + group: ReconGroupId; + label: string; + /** + * BRANCH — the underlying activity is booked at a specific branch (vouchers, cheques, + * customer digital transactions, share members, fixed assets), so Head Office sees a branch + * selector + consolidated view and a branch login is locked to its own branch — same pattern + * as the NPA module. + * HEAD_OFFICE — the activity has no branch dimension in real life (bank-wide P&L/GL control, + * treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch + * consolidation) — only Head Office can open these; a branch login sees a restricted notice. + */ + scope: ReconScope; + sideALabel: string; + sideBLabel: string; + referenceLabel: string; + /** Only set for the one genuinely three-sided report (Three-way Digital Reconciliation). */ + extraLabel?: string; +} + +export const RECON_TYPES: ReconTypeMeta[] = [ + { + id: 'voucher', + group: 'core', + label: 'Voucher Reconciliation', + scope: 'BRANCH', + sideALabel: 'System Voucher Amount', + sideBLabel: 'Physical / Scanned Voucher Amount', + referenceLabel: 'Voucher No' + }, + { + id: 'sundry-dr-cr', + group: 'core', + label: 'Sundry Dr / Cr Reconciliation', + scope: 'BRANCH', + sideALabel: 'GL Balance', + sideBLabel: 'Party Ledger Balance', + referenceLabel: 'Party Code' + }, + { + id: 'pnl-impact', + group: 'core', + label: 'P&L Impact Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'P&L as per GL', + sideBLabel: 'P&L as per MIS Schedule', + referenceLabel: 'Income / Expense Head' + }, + { + id: 'gl-reconciliation', + group: 'core', + label: 'GL Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'Total Debit', + sideBLabel: 'Total Credit', + referenceLabel: 'GL Code' + }, + { + id: 'fixed-assets', + group: 'core', + label: 'Fixed Assets Reconciliation', + scope: 'BRANCH', + sideALabel: 'GL Asset Value (WDV)', + sideBLabel: 'Physical Verification Value', + referenceLabel: 'Asset Tag No' + }, + { + id: 'inter-company', + group: 'core', + label: 'Inter-Company Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'HO Books (Inter-Branch A/c)', + sideBLabel: 'Branch Books (HO A/c)', + referenceLabel: 'Branch' + }, + { + id: 'bank-reco', + group: 'bank', + label: 'Bank Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'Balance per Bank GL (Books)', + sideBLabel: 'Balance per Bank Statement', + referenceLabel: 'Bank Account' + }, + { + id: 'api-bank-reco', + group: 'bank', + label: 'API Based Bank Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'Core Banking Balance', + sideBLabel: 'API Feed Balance', + referenceLabel: 'Bank Account (API)' + }, + { + id: 'neft-rtgs', + group: 'bank', + label: 'NEFT / RTGS Reconciliation', + scope: 'BRANCH', + sideALabel: 'Amount per CBS', + sideBLabel: 'Amount per RBI Settlement File', + referenceLabel: 'UTR No' + }, + { + id: 'clearing-reco', + group: 'bank', + label: 'Clearing Reconciliation', + scope: 'BRANCH', + sideALabel: 'Cheque Amount per CBS', + sideBLabel: 'Amount per Clearing House (CTS)', + referenceLabel: 'Cheque No' + }, + { + id: 'udir', + group: 'digital', + label: 'UDIR (IMPS / UPI / ATM)', + scope: 'BRANCH', + sideALabel: 'Disputed Amount (Customer Claim)', + sideBLabel: 'NPCI UDIR Resolution Amount', + referenceLabel: 'UDIR Ticket No' + }, + { + id: 'three-way-digital', + group: 'digital', + label: 'Three-way Digital Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'Core Banking Record', + sideBLabel: 'Settlement Bank Record', + extraLabel: 'Switch / NPCI Record', + referenceLabel: 'Transaction Ref (RRN)' + }, + { + id: 'nach-reco', + group: 'digital', + label: 'NACH Reconciliation', + scope: 'BRANCH', + sideALabel: 'Mandate Amount per CBS', + sideBLabel: 'Amount per NPCI Response File', + referenceLabel: 'UMRN' + }, + { + id: 'share-member', + group: 'instruments', + label: 'Share Member Reconciliation', + scope: 'BRANCH', + sideALabel: 'Share GL Balance', + sideBLabel: 'Member Share Ledger Total', + referenceLabel: 'Folio No' + }, + { + id: 'invoice-payment', + group: 'compliance', + label: 'Invoice Payment', + scope: 'HEAD_OFFICE', + sideALabel: 'Invoice Amount (Vendor Bill)', + sideBLabel: 'Amount Paid per GL', + referenceLabel: 'Invoice No' + }, + { + id: 'tax-reco', + group: 'compliance', + label: 'Tax Reconciliation', + scope: 'HEAD_OFFICE', + sideALabel: 'Deducted / Collected per Books', + sideBLabel: 'Remitted to Government (Challan)', + referenceLabel: 'Challan No' + } +]; + +export const DEFAULT_RECON_ID = 'voucher'; + +export function reconMeta(id: string): ReconTypeMeta { + return RECON_TYPES.find((t) => t.id === id) || RECON_TYPES[0]; +} + +export interface ReconItem { + id: string; + reconTypeId: string; + branchCode: string; + branchName: string; + groupLabel: string; + reference: string; + description: string; + date: string; + sideAAmount: number; + sideBAmount: number; + extraAmount?: number; + status: ReconStatus; + remarks: string; +} + +export function difference(item: ReconItem): number { + return +(item.sideAAmount - item.sideBAmount).toFixed(2); +} + +export function isOpen(item: ReconItem): boolean { + return item.status === 'UNMATCHED' || item.status === 'PENDING'; +} + +// ------------------------------------------------------------------------- +// Date helpers (same shape as npa-data.ts, kept local so this module has no +// runtime dependency beyond the shared BRANCHES list) +// ------------------------------------------------------------------------- +function fmt(d: Date): string { + return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; +} +function addDays(d: Date, days: number): Date { + const r = new Date(d); + r.setDate(r.getDate() + days); + return r; +} +export function todayDDMMYYYY(): string { + return fmt(new Date()); +} + +// ------------------------------------------------------------------------- +// Seeded RNG — deterministic dataset across reloads (own seed, independent of npa-data.ts) +// ------------------------------------------------------------------------- +function mulberry32(seed: number): () => number { + return function random(): number { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rng = mulberry32(20260213); +const pick = (arr: T[]): T => arr[Math.floor(rng() * arr.length)]; +const between = (min: number, max: number): number => min + rng() * (max - min); +const round = (n: number): number => Math.round(n); + +const VENDOR_NAMES = [ + 'Om Stationery Suppliers', + 'Shubham Printers', + 'Shree Balaji AMC Services', + 'Metro Security Services', + 'Sai Computers & Networking', + 'Deccan Furniture Works', + 'Unity Housekeeping Services', + 'Precision Fire & Safety', + 'Konark Electricals', + 'Vasant Stationery Mart' +]; +const PARTY_NAMES = [ + 'Kiran Hardware', + 'Vishal Electronics', + 'Nilesh Electricals', + 'Ramdas Plumbing Works', + 'Anita Traders', + 'Ganesh Enterprises', + 'Om Textiles', + 'Balaji Agro Traders', + 'Sunil Auto Parts', + 'Meera Fabricators' +]; +const ASSET_NAMES = [ + 'Office Furniture Set', + 'Desktop Computer (Branch Counter)', + 'UPS 3 KVA', + 'Currency Counting Machine', + 'Air Conditioner 1.5 Ton', + 'CCTV Camera System', + 'Branch Generator 15 KVA', + 'Server Rack Cabinet', + 'Biometric Attendance Device', + 'Two-Wheeler (Recovery Officer)' +]; +const GL_HEADS = [ + 'Interest on Loans & Advances', + 'Interest on Investments', + 'Interest Paid on Deposits', + 'Commission & Exchange', + 'Locker Rent Income', + 'Establishment Charges', + 'Rent, Taxes & Lighting', + 'Printing & Stationery', + 'Depreciation on Fixed Assets', + 'Bad Debts Written Off', + 'Audit Fees', + 'Sundry Income' +]; +const BANK_ACCOUNTS = [ + 'SBI Current Account - 40012345678', + 'HDFC Bank Current Account - 5020012345', + 'ICICI Bank Current Account - 000305012345', + 'Axis Bank Current Account - 91020012345', + 'RBI Current Account (Banking Dept)' +]; + +interface GenOptions { + reconTypeId: string; + refPrefix: string; + refDigits: number; + descriptions: string[]; + minAmt: number; + maxAmt: number; + unmatchedRate: number; + branchScoped: boolean; + countPerBranch?: number; + totalCount?: number; + today: Date; +} + +function statusFor(unmatchedRate: number): ReconStatus { + const r = rng(); + if (r < unmatchedRate * 0.6) { + return 'UNMATCHED'; + } + if (r < unmatchedRate) { + return 'PENDING'; + } + return 'MATCHED'; +} + +function amountsFor(status: ReconStatus, minAmt: number, maxAmt: number): { a: number; b: number } { + const a = round(between(minAmt, maxAmt)); + if (status === 'MATCHED') { + return { a, b: a }; + } + const gapPct = between(0.02, 0.35); + const sign = rng() < 0.5 ? 1 : -1; + const b = round(a * (1 - sign * gapPct)); + return { a, b }; +} + +function generateGeneric(opts: GenOptions): ReconItem[] { + const items: ReconItem[] = []; + const meta = reconMeta(opts.reconTypeId); + const branchLoop = opts.branchScoped ? BRANCHES : [{ code: 'HO', name: 'Head Office' }]; + const perBranch = opts.countPerBranch ?? opts.totalCount ?? 20; + + branchLoop.forEach((branch, branchIdx) => { + const count = opts.branchScoped ? perBranch : (opts.totalCount ?? perBranch); + for (let i = 0; i < count; i++) { + const status = statusFor(opts.unmatchedRate); + const { a, b } = amountsFor(status, opts.minAmt, opts.maxAmt); + const daysAgo = round(between(1, 75)); + const seq = String(branchIdx * 1000 + i + 1).padStart(opts.refDigits, '0'); + items.push({ + id: `${opts.reconTypeId}-${branch.code}-${i}`, + reconTypeId: opts.reconTypeId, + branchCode: branch.code, + branchName: branch.name, + groupLabel: GROUP_LABELS[meta.group], + reference: `${opts.refPrefix}${seq}`, + description: pick(opts.descriptions), + date: fmt(addDays(opts.today, -daysAgo)), + sideAAmount: a, + sideBAmount: status === 'MATCHED' ? a : b, + status, + remarks: status === 'MATCHED' ? '' : '' + }); + } + }); + return items; +} + +function generateInterCompany(today: Date): ReconItem[] { + return BRANCHES.map((b): ReconItem => { + const status = statusFor(0.25); + const { a, b: bAmt } = amountsFor(status, 200000, 3500000); + return { + id: `inter-company-${b.code}`, + reconTypeId: 'inter-company', + branchCode: 'HO', + branchName: 'Head Office', + groupLabel: GROUP_LABELS.core, + reference: b.name, + description: `Inter-branch settlement account — ${b.name}`, + date: fmt(addDays(today, -round(between(1, 10)))), + sideAAmount: a, + sideBAmount: status === 'MATCHED' ? a : bAmt, + status, + remarks: '' + }; + }); +} + +function generateBankReco(reconTypeId: string, today: Date, refPrefix: string): ReconItem[] { + const templates = [ + 'Cheque issued, not yet presented', + 'Cheque deposited, not yet credited by bank', + 'Bank charges debited, not yet booked in CBS', + 'Interest credited by bank, not yet booked in CBS', + 'Direct credit by remitter, not yet identified', + 'Standing instruction debited by bank, pending booking', + 'RTGS outward debited by bank, in-transit in CBS' + ]; + const items: ReconItem[] = []; + BANK_ACCOUNTS.forEach((acct, idx) => { + const lineCount = reconTypeId === 'api-bank-reco' ? 5 : 6; + for (let i = 0; i < lineCount; i++) { + const status = statusFor(0.55); + const { a, b } = amountsFor(status, 15000, 850000); + items.push({ + id: `${reconTypeId}-${idx}-${i}`, + reconTypeId, + branchCode: 'HO', + branchName: 'Head Office', + groupLabel: GROUP_LABELS.bank, + reference: `${refPrefix}${String(idx + 1).padStart(2, '0')}${String(i + 1).padStart(3, '0')}`, + description: `${acct} — ${pick(templates)}`, + date: fmt(addDays(today, -round(between(1, 30)))), + sideAAmount: a, + sideBAmount: status === 'MATCHED' ? a : b, + status, + remarks: '' + }); + } + }); + return items; +} + +function generateThreeWayDigital(today: Date): ReconItem[] { + const channels = [ + 'IMPS', + 'UPI', + 'NEFT', + 'RTGS', + 'ATM (NFS)' + ]; + const items: ReconItem[] = []; + for (let i = 0; i < 45; i++) { + const status = statusFor(0.12); + const core = round(between(500, 250000)); + const switchAmt = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.2))); + const settlement = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.15))); + items.push({ + id: `three-way-digital-${i}`, + reconTypeId: 'three-way-digital', + branchCode: 'HO', + branchName: 'Head Office', + groupLabel: GROUP_LABELS.digital, + reference: `RRN${String(700000 + i)}`, + description: `${pick(channels)} transaction — Core vs Switch vs Settlement Bank`, + date: fmt(addDays(today, -round(between(1, 20)))), + sideAAmount: core, + sideBAmount: settlement, + extraAmount: switchAmt, + status, + remarks: '' + }); + } + return items; +} + +function generateAll(): ReconItem[] { + const today = new Date(); + return [ + ...generateGeneric({ + reconTypeId: 'voucher', + refPrefix: 'JV/26/', + refDigits: 5, + descriptions: [ + 'Cash deposit voucher', + 'Cash withdrawal voucher', + 'Journal entry — interest provision', + 'Journal entry — expense booking', + 'Transfer voucher — inter-GL', + 'Debit voucher — charges recovery', + 'Credit voucher — refund adjustment' + ], + minAmt: 500, + maxAmt: 250000, + unmatchedRate: 0.08, + branchScoped: true, + countPerBranch: 15, + today + }), + ...generateGeneric({ + reconTypeId: 'sundry-dr-cr', + refPrefix: 'PTY-', + refDigits: 4, + descriptions: PARTY_NAMES, + minAmt: 5000, + maxAmt: 180000, + unmatchedRate: 0.18, + branchScoped: true, + countPerBranch: 10, + today + }), + ...generateGeneric({ + reconTypeId: 'pnl-impact', + refPrefix: 'GLH-', + refDigits: 3, + descriptions: GL_HEADS, + minAmt: 40000, + maxAmt: 4200000, + unmatchedRate: 0.15, + branchScoped: false, + totalCount: 24, + today + }), + ...generateGeneric({ + reconTypeId: 'gl-reconciliation', + refPrefix: 'GL-', + refDigits: 6, + descriptions: [ + 'Suspense account clearance check', + 'Control account tally — deposits', + 'Control account tally — advances', + 'Inter-branch adjustment head', + 'Sundry assets/liabilities head', + ...GL_HEADS + ], + minAmt: 20000, + maxAmt: 5200000, + unmatchedRate: 0.1, + branchScoped: false, + totalCount: 42, + today + }), + ...generateGeneric({ + reconTypeId: 'fixed-assets', + refPrefix: 'FA-', + refDigits: 4, + descriptions: ASSET_NAMES, + minAmt: 8000, + maxAmt: 650000, + unmatchedRate: 0.12, + branchScoped: true, + countPerBranch: 8, + today + }), + ...generateInterCompany(today), + ...generateBankReco('bank-reco', today, 'BRS-'), + ...generateBankReco('api-bank-reco', today, 'API-'), + ...generateGeneric({ + reconTypeId: 'neft-rtgs', + refPrefix: 'UTR', + refDigits: 12, + descriptions: [ + 'RTGS outward — beneficiary bank settlement', + 'RTGS inward — remitting bank settlement', + 'NEFT outward — beneficiary bank settlement', + 'NEFT inward — remitting bank settlement' + ], + minAmt: 25000, + maxAmt: 1800000, + unmatchedRate: 0.06, + branchScoped: true, + countPerBranch: 12, + today + }), + ...generateGeneric({ + reconTypeId: 'clearing-reco', + refPrefix: 'CHQ-', + refDigits: 6, + descriptions: [ + 'Outward clearing — cheque sent', + 'Inward clearing — cheque received', + 'CTS grid settlement adjustment', + 'Cheque return — insufficient funds' + ], + minAmt: 10000, + maxAmt: 620000, + unmatchedRate: 0.09, + branchScoped: true, + countPerBranch: 10, + today + }), + ...generateGeneric({ + reconTypeId: 'udir', + refPrefix: 'UDIR-', + refDigits: 5, + descriptions: [ + 'IMPS failed but debited — customer dispute', + 'UPI transaction disputed — merchant not credited', + 'ATM cash-not-dispensed but account debited', + 'UPI duplicate debit dispute' + ], + minAmt: 500, + maxAmt: 45000, + unmatchedRate: 0.35, + branchScoped: true, + countPerBranch: 4, + today + }), + ...generateThreeWayDigital(today), + ...generateGeneric({ + reconTypeId: 'nach-reco', + refPrefix: 'UMRN', + refDigits: 10, + descriptions: [ + 'NACH debit — loan EMI collection', + 'NACH debit — gold loan EMI collection', + 'NACH credit — salary/DBT disbursement', + 'NACH mandate return — insufficient funds' + ], + minAmt: 1500, + maxAmt: 85000, + unmatchedRate: 0.14, + branchScoped: true, + countPerBranch: 8, + today + }), + ...generateGeneric({ + reconTypeId: 'share-member', + refPrefix: 'FOL-', + refDigits: 5, + descriptions: [ + 'Regular member — share balance', + 'Nominal member — share balance', + 'Share transfer on account closure', + 'Share application pending allotment' + ], + minAmt: 500, + maxAmt: 25000, + unmatchedRate: 0.07, + branchScoped: true, + countPerBranch: 9, + today + }), + ...generateGeneric({ + reconTypeId: 'invoice-payment', + refPrefix: 'INV-', + refDigits: 5, + descriptions: VENDOR_NAMES, + minAmt: 3000, + maxAmt: 380000, + unmatchedRate: 0.13, + branchScoped: false, + totalCount: 30, + today + }), + ...generateGeneric({ + reconTypeId: 'tax-reco', + refPrefix: 'CHLN-', + refDigits: 4, + descriptions: [ + 'TDS on FD/RD interest payouts', + 'TDS on salary payments', + 'TDS on contractor/vendor payments', + 'GST on service charges collected', + 'GST on locker rent collected', + 'TCS on cash transactions' + ], + minAmt: 8000, + maxAmt: 620000, + unmatchedRate: 0.11, + branchScoped: false, + totalCount: 18, + today + }) + ]; +} + +export const RECON_ITEMS: ReconItem[] = generateAll(); + +// ------------------------------------------------------------------------- +// KPI helpers — always computed from the item list handed in, so a filtered +// (branch-scoped) view and the full book can never disagree on a total. +// ------------------------------------------------------------------------- +export function totalSideA(items: ReconItem[]): number { + return items.reduce((s, i) => s + i.sideAAmount, 0); +} +export function totalSideB(items: ReconItem[]): number { + return items.reduce((s, i) => s + i.sideBAmount, 0); +} +export function totalDifference(items: ReconItem[]): number { + return items.reduce((s, i) => s + Math.abs(difference(i)), 0); +} +export function countByStatus(items: ReconItem[], status: ReconStatus): number { + return items.filter((i) => i.status === status).length; +} +export function matchRate(items: ReconItem[]): number { + return items.length ? (countByStatus(items, 'MATCHED') / items.length) * 100 : 100; +} diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html new file mode 100644 index 000000000..6b5251986 --- /dev/null +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html @@ -0,0 +1,271 @@ + + +
+
+
+
+ RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }} + {{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }} +
+

{{ meta.label }}

+

+ {{ meta.sideALabel }} vs {{ meta.sideBLabel }} + @if (meta.extraLabel) { + vs {{ meta.extraLabel }} + } + — as on {{ asOnDate }}. +

+
+
+ @if (meta.scope === 'BRANCH') { + @if (isHeadOffice) { + + } @else { + + } + } @else { + + } + + + @if (!isRestricted) { + + } +
+
+ + @if (!isRestricted) { +
+
+ {{ meta.sideALabel }} + ₹{{ totalA | number: '1.0-0' }} + {{ items.length }} items +
+
+ {{ meta.sideBLabel }} + ₹{{ totalB | number: '1.0-0' }} + {{ scopeName }} +
+
+ Absolute Difference + ₹{{ totalDiff | number: '1.0-0' }} + {{ matchPct | number: '1.1-1' }}% matched +
+
+ Open Items + {{ unmatchedCount + pendingCount }} + {{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved +
+
+ } + +
+ @for (g of groups; track g) { + + } +
+ +
+ @for (m of groupOf(activeGroup); track m.id) { + + } +
+ + @if (isRestricted) { +
+
+ +
Managed Centrally by Head Office
+

+ {{ meta.label }} has no branch-level dimension in real-world banking — it is reconciled centrally by the Head + Office Accounts/Treasury team. Your login ({{ currentOfficeName }}) does not have access to this screen. + Contact Head Office if you need a figure from this reconciliation. +

+
+
+ } @else { +
+ +
+ + + + + + @if (meta.scope === 'BRANCH') { + + } + + + + @if (meta.extraLabel) { + + } + + + + + + + @for (item of paged(items); track item.id) { + + + + @if (meta.scope === 'BRANCH') { + + } + + + + @if (meta.extraLabel) { + + } + + + + + } @empty { + + + + } + +
{{ meta.referenceLabel }}DescriptionBranchDate{{ meta.sideALabel }}{{ meta.sideBLabel }}{{ meta.extraLabel }}DifferenceStatusRemarks / Action
+ {{ item.reference }} + {{ item.description }}{{ item.branchName }}{{ item.date }}₹{{ item.sideAAmount | number: '1.0-0' }}₹{{ item.sideBAmount | number: '1.0-0' }}₹{{ item.extraAmount | number: '1.0-0' }} + ₹{{ diff(item) | number: '1.0-0' }} + + {{ item.status }} + + @if (isItemOpen(item)) { +
+ + +
+ } @else if (item.status === 'RESOLVED') { + {{ item.remarks }} + } +
+ No {{ meta.label }} items for this branch. +
+
+ @if (items.length > pageSize) { +
+ Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }} +
+ + Page {{ currentPage }} of {{ pageCount(items) }} + +
+
+ } +
+ } +
+ +@if (showExportModal) { +
+
+
+ Export to Excel — Sign-off (Optional) + +
+

+ Fill in Prepared / Checked / Approved details if this copy needs them for the record — or leave them blank and + download straight away. +

+
+
+ Prepared By + + +
+
+ Checked By + + +
+
+ Approved By + + +
+
+
+ + +
+
+
+} + +
{{ toastMsg }}
diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts new file mode 100644 index 000000000..dc13bba98 --- /dev/null +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts @@ -0,0 +1,418 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Subscription } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { + RECON_TYPES, + RECON_ITEMS, + BRANCHES, + GROUP_LABELS, + DEFAULT_RECON_ID, + ReconGroupId, + ReconTypeMeta, + ReconItem, + ReconStatus, + reconMeta, + difference, + isOpen, + totalSideA, + totalSideB, + totalDifference, + countByStatus, + matchRate, + todayDDMMYYYY +} from '../reconciliation-data'; + +/** Optional "Prepared / Checked / Approved" details captured in the Export to Excel popup — + * purely a one-off snapshot for that download, not a persisted workflow/sign-off state. */ +interface ReconExportSignOff { + preparedByName: string; + preparedByDesg: string; + checkedByName: string; + checkedByDesg: string; + approvedByName: string; + approvedByDesg: string; +} + +function blankExportSignOff(): ReconExportSignOff { + return { + preparedByName: '', + preparedByDesg: '', + checkedByName: '', + checkedByDesg: '', + approvedByName: '', + approvedByDesg: '' + }; +} + +/** + * The "Reconciliation" mega-menu — 16 screens across 5 groups (Core & Books / Bank & Clearing / + * Digital & Payments / Instruments & Members / Compliance & Financials) — collapsed into one + * route-param-driven component, mirroring the NPA module's pattern. Every screen shares the same + * generic two-sided (Side A vs Side B) reconciliation model in reconciliation-data.ts, and the + * same Head-Office-vs-branch access split used by NPA and Cash/Clearing/Transfer: activity that + * genuinely happens at a branch (vouchers, cheques, digital transactions, share members, fixed + * assets) is branch-scoped; activity with no branch dimension in real life (bank-wide P&L/GL + * control, treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch + * consolidation) is Head-Office-only. + */ +@Component({ + selector: 'mifosx-reconciliation-report', + templateUrl: './reconciliation-report.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../../investment/investment-shared.scss', + '../../npa/npa-shared.scss', + '../reconciliation-shared.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ] +}) +export class ReconciliationReportComponent implements OnInit, OnDestroy { + readonly reportMeta = RECON_TYPES; + readonly groupLabels = GROUP_LABELS; + readonly branches = BRANCHES; + readonly groups: ReconGroupId[] = [ + 'core', + 'bank', + 'digital', + 'instruments', + 'compliance' + ]; + asOnDate = todayDDMMYYYY(); + + activeGroup: ReconGroupId = 'core'; + activeReport = DEFAULT_RECON_ID; + + isHeadOffice = false; + currentOfficeName = 'Branch Office'; + private ownBranchCode: string | null = null; + + /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ + branchFilter = ''; + + readonly pageSize = 20; + currentPage = 1; + + remarksDraft: Record = {}; + + generating = false; + showExportModal = false; + exportSignOff: ReconExportSignOff = blankExportSignOff(); + + toastMsg = ''; + toastVisible = false; + private toastTimer: ReturnType | undefined; + private sub?: Subscription; + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly auth = inject(AuthenticationService); + + ngOnInit(): void { + this.sub = this.route.paramMap.subscribe((params) => { + const id = params.get('reportId') || DEFAULT_RECON_ID; + const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[0]; + this.activeReport = meta.id; + this.activeGroup = meta.group; + this.currentPage = 1; + }); + + const creds = this.auth.getCredentials(); + this.isHeadOffice = this.auth.isHeadOfficeUser(); + this.currentOfficeName = creds?.officeName || 'Branch Office'; + if (this.isHeadOffice) { + this.branchFilter = ''; + } else { + this.ownBranchCode = + this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null; + this.branchFilter = this.ownBranchCode || this.branches[0].code; + } + } + + ngOnDestroy(): void { + this.sub?.unsubscribe(); + } + + goToReport(reportId: string): void { + this.router.navigate([ + '/reconciliation', + reportId + ]); + } + + groupOf(group: ReconGroupId): ReconTypeMeta[] { + return this.reportMeta.filter((m) => m.group === group); + } + + setBranch(code: string): void { + this.branchFilter = code; + this.currentPage = 1; + } + + get meta(): ReconTypeMeta { + return reconMeta(this.activeReport); + } + + /** True when a branch-level login has opened a Head-Office-only reconciliation type. */ + get isRestricted(): boolean { + return this.meta.scope === 'HEAD_OFFICE' && !this.isHeadOffice; + } + + get scopeName(): string { + if (this.meta.scope === 'HEAD_OFFICE') { + return 'Head Office'; + } + if (!this.isHeadOffice) { + return this.currentOfficeName; + } + return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches'; + } + + get items(): ReconItem[] { + const all = RECON_ITEMS.filter((i) => i.reconTypeId === this.activeReport); + if (this.meta.scope !== 'BRANCH' || !this.branchFilter) { + return all; + } + return all.filter((i) => i.branchCode === this.branchFilter); + } + + diff(item: ReconItem): number { + return difference(item); + } + + isItemOpen(item: ReconItem): boolean { + return isOpen(item); + } + + badgeClass(status: ReconStatus): string { + return `recon-badge recon-badge--${status.toLowerCase()}`; + } + + // -------------------- KPIs (always over the full `items` list, never the paged slice) -------------------- + get totalA(): number { + return totalSideA(this.items); + } + get totalB(): number { + return totalSideB(this.items); + } + get totalDiff(): number { + return totalDifference(this.items); + } + get matchedCount(): number { + return countByStatus(this.items, 'MATCHED'); + } + get unmatchedCount(): number { + return countByStatus(this.items, 'UNMATCHED'); + } + get pendingCount(): number { + return countByStatus(this.items, 'PENDING'); + } + get resolvedCount(): number { + return countByStatus(this.items, 'RESOLVED'); + } + get matchPct(): number { + return matchRate(this.items); + } + + // -------------------- Pagination -------------------- + paged(list: T[]): T[] { + const start = (this.currentPage - 1) * this.pageSize; + return list.slice(start, start + this.pageSize); + } + pageCount(list: unknown[]): number { + return Math.max(1, Math.ceil(list.length / this.pageSize)); + } + rangeStart(list: unknown[]): number { + return list.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1; + } + rangeEnd(list: unknown[]): number { + return Math.min(this.currentPage * this.pageSize, list.length); + } + goToPage(page: number, list: unknown[]): void { + this.currentPage = Math.min(Math.max(1, page), this.pageCount(list)); + } + + // -------------------- Actions -------------------- + markReconciled(item: ReconItem): void { + const remark = (this.remarksDraft[item.id] || '').trim(); + item.status = 'RESOLVED'; + item.remarks = remark || 'Reconciled — difference verified and squared off.'; + delete this.remarksDraft[item.id]; + this.toast(`${this.meta.referenceLabel} ${item.reference} marked as reconciled.`); + } + + generate(): void { + this.currentPage = 1; + this.toast(`${this.meta.label} refreshed as on ${this.asOnDate}.`); + } + + // -------------------- Export to Excel -------------------- + /** "Export to Excel" opens this popup first — Prepared/Checked/Approved details are entirely + * optional; leaving them blank and clicking Download exports immediately with no sign-off + * rows added. */ + openExportModal(): void { + this.exportSignOff = blankExportSignOff(); + this.showExportModal = true; + } + + closeExportModal(): void { + this.showExportModal = false; + } + + confirmExport(): void { + this.showExportModal = false; + void this.exportExcel(); + } + + /** Strips characters Excel forbids in worksheet names (\ / ? * [ ]) and caps the length. */ + private safeSheetName(name: string): string { + return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31); + } + + private async exportExcel(): Promise { + this.generating = true; + try { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBSNexora'; + wb.created = new Date(); + // Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars — several report + // labels here have a "/" (e.g. "NEFT / RTGS Reconciliation"), which made addWorksheet() + // throw and silently abort the whole export. + const sheet = wb.addWorksheet(this.safeSheetName(this.meta.label)); + + const headers = [ + this.meta.referenceLabel, + 'Description', + ...(this.meta.scope === 'BRANCH' ? ['Branch'] : []), + 'Date', + this.meta.sideALabel, + this.meta.sideBLabel, + ...(this.meta.extraLabel ? [this.meta.extraLabel] : []), + 'Difference', + 'Status', + 'Remarks' + ]; + const widths = [ + 18, + 34, + ...(this.meta.scope === 'BRANCH' ? [16] : []), + 12, + 20, + 20, + ...(this.meta.extraLabel ? [20] : []), + 16, + 14, + 30 + ]; + sheet.columns = widths.map((w) => ({ width: w })); + + const title = sheet.addRow([this.meta.label]); + sheet.mergeCells(title.number, 1, title.number, headers.length); + title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } }; + title.alignment = { horizontal: 'center', vertical: 'middle' }; + title.height = 24; + title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } }; + + const meta = sheet.addRow([`Scope: ${this.scopeName} · As On: ${this.asOnDate}`]); + sheet.mergeCells(meta.number, 1, meta.number, headers.length); + meta.font = { italic: true, size: 10 }; + sheet.addRow([]); + + const head = sheet.addRow(headers); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => { + c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; + c.border = { bottom: { style: 'thin' } }; + }); + + for (const item of this.items) { + const row: (string | number | null)[] = [ + item.reference, + item.description, + ...(this.meta.scope === 'BRANCH' ? [item.branchName] : []), + item.date, + item.sideAAmount, + item.sideBAmount, + ...(this.meta.extraLabel ? [item.extraAmount ?? null] : []), + this.diff(item), + item.status, + item.remarks + ]; + const excelRow = sheet.addRow(row); + row.forEach((v, idx) => { + if (typeof v === 'number') { + excelRow.getCell(idx + 1).numFmt = '#,##0.00'; + } + }); + } + sheet.views = [{ state: 'frozen', ySplit: 4 }]; + + const so = this.exportSignOff; + const hasSignOff = [ + so.preparedByName, + so.checkedByName, + so.approvedByName + ].some((v) => v.trim()); + if (hasSignOff) { + sheet.addRow([]); + sheet.addRow([]); + const signHead = sheet.addRow([ + 'Prepared By', + so.preparedByName ? `${so.preparedByName}${so.preparedByDesg ? ' (' + so.preparedByDesg + ')' : ''}` : '—', + '', + 'Checked By', + so.checkedByName ? `${so.checkedByName}${so.checkedByDesg ? ' (' + so.checkedByDesg + ')' : ''}` : '—', + '', + 'Approved By', + so.approvedByName ? `${so.approvedByName}${so.approvedByDesg ? ' (' + so.approvedByDesg + ')' : ''}` : '—' + ]); + signHead.font = { bold: true }; + } + + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.meta.label.replace(/[^\w]+/g, '-')}-${this.scopeName.replace(/[^\w]+/g, '-')}-${this.asOnDate}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.toast(`${this.meta.label} exported (${this.items.length} rows).`); + } catch (err) { + console.error('Reconciliation export failed', err); + this.toast('Could not generate the workbook — please retry.'); + } finally { + this.generating = false; + } + } + + private toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + if (this.toastTimer) { + clearTimeout(this.toastTimer); + } + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } +} diff --git a/src/app/reconciliation/reconciliation-routing.module.ts b/src/app/reconciliation/reconciliation-routing.module.ts new file mode 100644 index 000000000..148fd4127 --- /dev/null +++ b/src/app/reconciliation/reconciliation-routing.module.ts @@ -0,0 +1,41 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component'; +import { DEFAULT_RECON_ID } from './reconciliation-data'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'reconciliation', + data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }, + children: [ + { + path: '', + redirectTo: DEFAULT_RECON_ID, + pathMatch: 'full' + }, + { + path: ':reportId', + component: ReconciliationReportComponent, + data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' } + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ReconciliationRoutingModule {} diff --git a/src/app/reconciliation/reconciliation-shared.scss b/src/app/reconciliation/reconciliation-shared.scss new file mode 100644 index 000000000..1d6c4b26e --- /dev/null +++ b/src/app/reconciliation/reconciliation-shared.scss @@ -0,0 +1,189 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Reconciliation-status colouring layered on top of ../cashier/cashier-shared.scss (pg-*, +// cctr-*), ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast) and +// ../npa/npa-shared.scss (pg-pagination) — reused as-is since they're generic primitives. + +.recon-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + + &--matched { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } + + &--unmatched { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &--pending { + background: #fefce8; + color: #a16207; + border: 1px solid #fde68a; + } + + &--resolved { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + } +} + +.recon-scope-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; +} + +.recon-action-row { + display: flex; + align-items: center; + gap: 8px; +} + +.recon-remark-input { + height: 32px; + min-width: 160px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + font-family: inherit; + color: #0d1b2a; + background: #fff; +} + +.recon-signoff-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + padding: 16px; +} + +.recon-signoff-col { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + + input { + height: 34px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + font-family: inherit; + color: #0d1b2a; + background: #fff; + } +} + +.recon-signoff-role { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #0f4f3c; +} + +.recon-modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 55%); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.recon-modal { + width: 100%; + max-width: 820px; + max-height: 90vh; + overflow-y: auto; + background: #fff; + border-radius: 12px; + box-shadow: 0 20px 60px rgb(0 0 0 / 30%); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + border-bottom: 1px solid #e2e8f0; + } + + &__close { + border: none; + background: none; + font-size: 22px; + line-height: 1; + color: #64748b; + cursor: pointer; + padding: 0 4px; + + &:hover { + color: #0f172a; + } + } +} + +.recon-restricted { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 56px 24px; + text-align: center; + color: #64748b; + + &__icon { + font-size: 28px; + color: #94a3b8; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: #334155; + } + + &__body { + max-width: 440px; + font-size: 13px; + line-height: 1.5; + } +} diff --git a/src/app/reconciliation/reconciliation.module.ts b/src/app/reconciliation/reconciliation.module.ts new file mode 100644 index 000000000..ccbdd08d3 --- /dev/null +++ b/src/app/reconciliation/reconciliation.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { ReconciliationRoutingModule } from './reconciliation-routing.module'; +import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component'; + +@NgModule({ + imports: [ + SharedModule, + ReconciliationRoutingModule, + ReconciliationReportComponent + ] +}) +export class ReconciliationModule {}