This commit is contained in:
2026-08-02 11:49:32 +05:30
35 changed files with 6627 additions and 124 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
+2
View File
@@ -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,
@@ -31,6 +31,9 @@
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
@@ -209,43 +212,61 @@
? 'Clearing'
: 'Digital'
}}
— Transaction Details
— Transaction Details (Full Double Entry)
@if (glFilter !== null) {
· {{ glFilter }}
}
</div>
<table class="pg-table">
<table class="pg-table cctr-journal">
<thead>
<tr>
<th>Date</th>
<th>Voucher / Ref No</th>
<th>Account No</th>
<th>Account Name / Reference</th>
<th>Particulars</th>
<th>Dr / Cr</th>
<th>GL / Account</th>
<th class="num col-out">Debit</th>
<th class="num col-in">Credit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.voucherNo) {
<tr>
<tr class="cctr-journal-row cctr-journal-row--first">
<td>{{ asOnDate }}</td>
<td>
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
</td>
<td>{{ display(t.accountNo) }}</td>
<td>{{ t.accountName }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-out">
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
<td>
@if (t.debit > 0) {
₹{{ t.debit | number: '1.2-2' }}
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num col-in">
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
<td class="num"></td>
<td rowspan="2">
<span class="cctr-balanced-chip">✓ Balanced</span>
</td>
</tr>
<tr class="cctr-journal-row cctr-journal-row--second">
<td></td>
<td></td>
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
<td>
@if (t.credit > 0) {
₹{{ t.credit | number: '1.2-2' }}
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num"></td>
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
@@ -255,9 +276,14 @@
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total</td>
<td class="num">₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}</td>
<td class="num">₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}</td>
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td></td>
</tr>
</tfoot>
</table>
@@ -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;
}
@@ -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<TabKey, string> = {
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<void> {
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;
}
}
}
+88
View File
@@ -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<void> {
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);
}
+39 -17
View File
@@ -1295,6 +1295,9 @@
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
</button>
</div>
<!-- NPA Classification -->
<div class="deposit-menu-group">
@@ -1306,6 +1309,25 @@
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button>
</div>
<!-- Recovery Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Recovery Management</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
</button>
</div>
</div>
</mat-menu>
@@ -1337,27 +1359,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
</button>
@@ -1366,19 +1388,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
</button>
@@ -1387,15 +1409,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM)
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
</button>
@@ -1404,11 +1426,11 @@
<!-- Instruments & Members -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0" class="menu-item-live">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
</button>
@@ -1417,11 +1439,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
+27
View File
@@ -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));
@@ -25,6 +25,11 @@
}
</select>
</label>
@if (activeTab === 'holdings') {
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
}
</div>
</header>
@@ -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<typeof setTimeout> | undefined;
@@ -1092,6 +1094,68 @@ export class BondInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
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;
@@ -16,6 +16,13 @@
Part-Payment entries.
</p>
</div>
@if (activeTab === 'holdings') {
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
}
</header>
<div class="cctr-tabs">
@@ -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<typeof setTimeout> | undefined;
@@ -450,6 +452,68 @@ export class FdInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
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;
@@ -16,6 +16,11 @@
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="inv-kpis">
@@ -78,6 +83,58 @@
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<table class="pg-table">
<thead>
<tr>
<th>Instrument Type</th>
<th class="num">Target %</th>
<th class="num">Actual %</th>
<th class="num">Actual Value</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of allocationVsTarget; track a.type) {
<tr>
<td>{{ a.label }}</td>
<td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
<td
class="num"
[class.col-out]="a.statusLabel !== 'Within Target'"
[class.col-in]="a.statusLabel === 'Within Target'"
>
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
</td>
<td>
<span
class="inv-badge"
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
>
{{ a.statusLabel }}
</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">100%</td>
<td class="num">100%</td>
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
@if (maturingSoon.length === 0) {
@@ -144,3 +201,5 @@
</div>
</div>
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -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<typeof setTimeout> | 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<void> {
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);
}
}
+114 -6
View File
@@ -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<InstrumentType, string> = {
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<InvestmentProvisionStatus, string> = {
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<InstrumentType, number> = {
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',
+89
View File
@@ -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<void> {
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);
}
@@ -12,20 +12,33 @@
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
<h1 class="pg-title">Investment Interest</h1>
<p class="pg-sub">
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.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receivable'"
(click)="setTab('receivable')"
>
Interest Receivable
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'fdCalc'"
(click)="setTab('fdCalc')"
>
01 FD Interest Calculation
01 FD Interest Calculation &amp; Post
</button>
<button
type="button"
@@ -33,7 +46,15 @@
[class.pg-btn--active]="activeTab === 'bondCalc'"
(click)="setTab('bondCalc')"
>
02 Govt./Bond Int. Calculation
02 Govt./Bond Int. Calculation &amp; Post
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'receive'"
(click)="setTab('receive')"
>
Interest Receive
</button>
<button
type="button"
@@ -61,10 +82,106 @@
</button>
</div>
<!-- INTEREST RECEIVABLE -->
@if (activeTab === 'receivable') {
<div class="pg-card">
<div class="pos-section-label">
Interest Receivable — Posted but Not Yet Collected (₹{{ totalReceivable | number: '1.2-2' }})
</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Interest Posted</th>
<th class="num">Interest Received</th>
<th class="num col-in">Receivable Balance</th>
</tr>
</thead>
<tbody>
@for (h of receivableRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ posted(h) | number: '1.2-2' }}</td>
<td class="num">₹{{ received(h) | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ receivableBalance(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No outstanding interest receivable.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="6">Total Receivable</td>
<td class="num">₹{{ totalReceivable | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (suspenseRows.length > 0) {
<div class="pg-card">
<div class="pos-section-label">
Non-Performing Investments — Interest Held in Suspense (₹{{ totalSuspense | number: '1.2-2' }})
</div>
<p class="pg-sub" style="padding: 0 16px">
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.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Provision Required</th>
<th class="num col-out">Interest in Suspense</th>
</tr>
</thead>
<tbody>
@for (h of suspenseRows; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ provisionRequired(h) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ suspense(h) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="5">Total Suspense</td>
<td class="num">₹{{ totalSuspense | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
<!-- 01 FD INTEREST CALCULATION -->
@if (activeTab === 'fdCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - FD Interest Calculation</div>
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
@@ -87,7 +204,7 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate &amp; Post</button>
</div>
<table class="pg-table">
<thead>
@@ -125,13 +242,19 @@
</tr>
</tfoot>
</table>
@if (fdCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- 02 GOVT./BOND INT. CALCULATION -->
@if (activeTab === 'bondCalc') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</div>
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
@@ -164,7 +287,9 @@
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
Calculate &amp; Post
</button>
</div>
<table class="pg-table">
<thead>
@@ -204,6 +329,100 @@
</tr>
</tfoot>
</table>
@if (bondCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 12px 16px 16px">
{{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (see Interest Receivable tab for suspense amount).
</p>
}
</div>
}
<!-- INTEREST RECEIVE -->
@if (activeTab === 'receive') {
<div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script / Party</th>
<th>Type</th>
<th>Status</th>
<th class="num">Outstanding</th>
<th class="num">Amount to Receive</th>
<th></th>
</tr>
</thead>
<tbody>
@for (h of receiveTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName || h.party }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td>
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
</td>
<td class="num">₹{{ outstandingFor(h) | number: '1.2-2' }}</td>
<td class="num">
<input
type="number"
[(ngModel)]="receiveDrafts[h.certNo]"
[max]="outstandingFor(h)"
style="width: 120px; text-align: right"
/>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="receiveInterest(h)">Receive</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No interest outstanding for collection.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Interest Received Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Cert No</th>
<th>Particulars</th>
<th class="num col-in">Amount Received</th>
</tr>
</thead>
<tbody>
@for (t of receivedLog; track t.voucherNo) {
<tr>
<td>{{ t.txnDate }}</td>
<td>
<span class="pg-chip">{{ t.voucherNo }}</span>
</td>
<td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="pg-empty">No interest received yet.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
@@ -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<string, number | null> = {};
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | 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<void> {
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;
@@ -16,6 +16,11 @@
Market Detail masters.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
@@ -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<MasterTab, string> = {
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<typeof setTimeout> | 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<void> {
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;
@@ -16,6 +16,11 @@
same holdings/transactions used by the Entry screens.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-tabs">
@@ -692,3 +697,5 @@
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -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<typeof setTimeout> | 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<void> {
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);
}
}
+26
View File
@@ -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 {
@@ -15,6 +15,13 @@
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
</p>
</div>
@if (activeTab === 'holdings') {
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
}
</header>
<div class="cctr-tabs">
@@ -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<typeof setTimeout> | undefined;
@@ -252,6 +254,59 @@ export class ShareInvestmentComponent {
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
}
// -------------------- Export to Excel (Holdings register) --------------------
async exportExcel(): Promise<void> {
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;
+383 -21
View File
@@ -79,6 +79,26 @@ export interface RecoveryTxn {
mode: string;
}
/** One entry in an account's collection/recovery journey — the real-world touchpoints a CBS
* Recovery Management System logs against an overdue loan, in the order they actually happened. */
export type RecoveryEventType =
| 'SMS Reminder'
| 'IVR / Auto Call'
| 'Tele-calling'
| 'Overdue Call'
| 'Field Visit'
| 'Formal Overdue Letter'
| 'Recovery Agent Assigned'
| 'Demand Notice'
| 'Payment Received';
export interface RecoveryEvent {
date: string;
type: RecoveryEventType;
channel: string;
remarks: string;
}
export interface LoanAccount {
accountNo: string;
customerName: string;
@@ -88,6 +108,7 @@ export interface LoanAccount {
productCode: string;
productName: string;
secured: boolean;
repaymentType: RepaymentType;
sanctionedAmount: number;
disbursementDate: string;
interestRate: number;
@@ -96,11 +117,27 @@ export interface LoanAccount {
overdueDays: number;
overdueDaysQuarterAgo: number;
collateralValue: number;
/** Current estimated market value of the pledged security — can run above or below
* `collateralValue` (the value recorded at sanction/last valuation), which is exactly what the
* Exception Check / Agriculture-Gold Market Value reports exist to catch. 0 for unsecured loans. */
collateralMarketValue: number;
/** Fixed EMI for an EMI-repayment loan (standard reducing-balance formula on the sanctioned
* amount and tenure) — 0 for OVERDRAWN/BULLET loans, which aren't due on a fixed instalment. */
emiAmount: number;
/** Drawing limit for an OVERDRAWN (CC/OD/KCC) account — kept below the sanctioned amount by a
* margin, same as a real cash-credit facility. 0 for EMI/BULLET loans. */
drawingPower: number;
/** Bullet-repayment due date (principal falls due in full on this date) — '—' for EMI/OVERDRAWN
* loans, which don't have a single bullet maturity. */
maturityDate: string;
lastPaymentDate: string;
isLossAsset: boolean;
isRestructured: boolean;
provisionCoverageRatio: number;
recoveries: RecoveryTxn[];
/** Chronological log of collection touchpoints (SMS/call/visit/notice) plus actual recoveries —
* empty for an account that has never gone overdue. */
collectionTimeline: RecoveryEvent[];
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
npaReason: string;
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
@@ -174,6 +211,17 @@ export const BRANCHES: BranchMaster[] = [
{ code: 'BR08', name: 'Kolhapur Branch' }
];
/** How the loan is contractually repaid — drives the Recovery Management System's per-type
* overdue check (EMI shortfall / drawing-power breach / bullet-maturity lapse) and the
* Expected-vs-Actual Recovery figures, since each type is "due" on a different basis. */
export type RepaymentType = 'EMI' | 'OVERDRAWN' | 'BULLET';
export const REPAYMENT_TYPE_LABELS: Record<RepaymentType, string> = {
EMI: 'Term Loan (EMI)',
OVERDRAWN: 'Overdrawn (CC / OD)',
BULLET: 'Bullet Repayment'
};
interface ProductMaster {
code: string;
name: string;
@@ -182,6 +230,11 @@ interface ProductMaster {
maxAmt: number;
minRate: number;
maxRate: number;
repaymentType: RepaymentType;
/** Sanctioned tenure in months — drives the EMI formula for EMI-type products and the
* bullet maturity date for BULLET-type products. Renewal cycle (not a repayment schedule)
* for OVERDRAWN/revolving products. */
tenureMonths: number;
}
export const LOAN_PRODUCTS: ProductMaster[] = [
{
@@ -191,15 +244,87 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000,
maxAmt: 5000000,
minRate: 11,
maxRate: 13.5
maxRate: 13.5,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '02',
name: 'Term Loan - MSME',
secured: true,
minAmt: 300000,
maxAmt: 3000000,
minRate: 11,
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 60
},
{
code: '03',
name: 'Housing Loan',
secured: true,
minAmt: 1000000,
maxAmt: 4000000,
minRate: 8.5,
maxRate: 10.5,
repaymentType: 'EMI',
tenureMonths: 180
},
{
code: '04',
name: 'Vehicle Loan',
secured: true,
minAmt: 300000,
maxAmt: 1500000,
minRate: 9,
maxRate: 11.5,
repaymentType: 'EMI',
tenureMonths: 84
},
{
code: '05',
name: 'Gold Loan',
secured: true,
minAmt: 50000,
maxAmt: 1000000,
minRate: 9,
maxRate: 12,
repaymentType: 'BULLET',
tenureMonths: 12
},
{
code: '06',
name: 'Kisan Credit Card',
secured: true,
minAmt: 100000,
maxAmt: 500000,
minRate: 7,
maxRate: 9,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '07',
name: 'Personal Loan',
secured: false,
minAmt: 50000,
maxAmt: 500000,
minRate: 13,
maxRate: 16,
repaymentType: 'EMI',
tenureMonths: 36
},
{
code: '08',
name: 'Education Loan',
secured: false,
minAmt: 200000,
maxAmt: 1500000,
minRate: 10,
maxRate: 12,
repaymentType: 'EMI',
tenureMonths: 84
},
{ code: '02', name: 'Term Loan - MSME', secured: true, minAmt: 300000, maxAmt: 3000000, minRate: 11, maxRate: 14 },
{ code: '03', name: 'Housing Loan', secured: true, minAmt: 1000000, maxAmt: 4000000, minRate: 8.5, maxRate: 10.5 },
{ code: '04', name: 'Vehicle Loan', secured: true, minAmt: 300000, maxAmt: 1500000, minRate: 9, maxRate: 11.5 },
{ code: '05', name: 'Gold Loan', secured: true, minAmt: 50000, maxAmt: 1000000, minRate: 9, maxRate: 12 },
{ code: '06', name: 'Kisan Credit Card', secured: true, minAmt: 100000, maxAmt: 500000, minRate: 7, maxRate: 9 },
{ code: '07', name: 'Personal Loan', secured: false, minAmt: 50000, maxAmt: 500000, minRate: 13, maxRate: 16 },
{ code: '08', name: 'Education Loan', secured: false, minAmt: 200000, maxAmt: 1500000, minRate: 10, maxRate: 12 },
{
code: '09',
name: 'SHG / Micro Finance Loan',
@@ -207,7 +332,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 20000,
maxAmt: 200000,
minRate: 12,
maxRate: 14
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 24
},
{
code: '10',
@@ -216,7 +343,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000,
maxAmt: 2500000,
minRate: 11,
maxRate: 13
maxRate: 13,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
}
];
@@ -345,6 +474,113 @@ function pickBucket(): StageBucket {
return BUCKETS[0];
}
/** Standard reducing-balance EMI formula on the original sanctioned amount and tenure — the same
* fixed instalment the loan was actually sanctioned with, not something recomputed off the
* current (partly-repaid) outstanding balance. */
function calcEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
const r = annualRatePct / 12 / 100;
if (r === 0) {
return round(principal / tenureMonths);
}
const factor = Math.pow(1 + r, tenureMonths);
return round((principal * r * factor) / (factor - 1));
}
interface TimelineCheckpoint {
atDay: number;
type: RecoveryEventType;
channel: string;
remarks: string;
}
/** The real-world escalation ladder a CBS Recovery Management System runs an overdue account
* through — each checkpoint only fires once the account has actually been overdue that long, so
* a 10-day-overdue account shows just the SMS/call steps while a 200-day NPA shows the full
* ladder up to the demand notice. */
const TIMELINE_CHECKPOINTS: TimelineCheckpoint[] = [
{
atDay: 3,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Auto SMS reminder sent for the overdue instalment/interest.'
},
{
atDay: 7,
type: 'IVR / Auto Call',
channel: 'IVR System',
remarks: 'Automated voice-call reminder placed to the registered mobile number.'
},
{
atDay: 15,
type: 'Tele-calling',
channel: 'Branch Tele-calling Desk',
remarks: 'Branch staff called the customer to follow up on the continuing overdue.'
},
{
atDay: 25,
type: 'Overdue Call',
channel: 'Recovery Officer',
remarks: 'Recovery officer placed a personal follow-up call.'
},
{
atDay: 30,
type: 'Field Visit',
channel: 'Branch Staff',
remarks: 'Field visit conducted at the registered address — account under SMA monitoring.'
},
{
atDay: 45,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Second reminder SMS sent — account still overdue.'
},
{
atDay: 60,
type: 'Formal Overdue Letter',
channel: 'Registered Post',
remarks: 'Formal overdue letter dispatched — account crossed into SMA-2 monitoring.'
},
{
atDay: 75,
type: 'Recovery Agent Assigned',
channel: 'External Recovery Agency',
remarks: 'Case assigned to a recovery agent for intensified follow-up.'
},
{
atDay: 91,
type: 'Demand Notice',
channel: 'Recovery Officer',
remarks: 'Account classified NPA — formal demand notice process initiated.'
}
];
/** Builds the account's full collection journey — every escalation checkpoint the account has
* actually reached, interleaved date-wise with its real cash recoveries — so the Account Detail
* screen can show one combined, chronological timeline instead of two disconnected lists. */
function buildCollectionTimeline(overdueDays: number, recoveries: RecoveryTxn[], today: Date): RecoveryEvent[] {
const events: RecoveryEvent[] = [];
for (const cp of TIMELINE_CHECKPOINTS) {
if (cp.atDay <= overdueDays) {
events.push({
date: fmt(addDays(today, -(overdueDays - cp.atDay))),
type: cp.type,
channel: cp.channel,
remarks: cp.remarks
});
}
}
for (const r of recoveries) {
events.push({
date: r.date,
type: 'Payment Received',
channel: r.mode,
remarks: `Recovery of ₹${r.amount.toLocaleString('en-IN')} received via ${r.mode}.`
});
}
events.sort((a, b) => parseDMY(a.date).getTime() - parseDMY(b.date).getTime());
return events;
}
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
* advanced/stressed the classification, the more likely the bank has already escalated. */
function legalActionFor(stage: NpaStage): LegalActionStatus {
@@ -398,6 +634,26 @@ function generateAccounts(count: number): LoanAccount[] {
const overdueAmount = round(outstandingBalance * overdueFrac);
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
// Current market value of the pledged security vs its value at sanction: gold trends upward
// over time (real-world gold-price appreciation); crop/agri collateral (KCC) is volatile in
// both directions, matching how "Crop Failure" shows up as an NPA reason; other secured
// collateral (property/vehicle) moves in a narrower band. Unsecured loans have none.
let marketValueFactor = 0;
if (product.code === '05') {
marketValueFactor = between(1.05, 1.35);
} else if (product.code === '06') {
marketValueFactor = between(0.55, 1.2);
} else if (product.secured) {
marketValueFactor = between(0.85, 1.15);
}
const collateralMarketValue = product.secured ? round(collateralValue * marketValueFactor) : 0;
const emiAmount =
product.repaymentType === 'EMI' ? calcEmi(sanctionedAmount, interestRate, product.tenureMonths) : 0;
const drawingPower = product.repaymentType === 'OVERDRAWN' ? round(sanctionedAmount * between(0.75, 0.9)) : 0;
const maturityDate =
product.repaymentType === 'BULLET' ? fmt(addDays(parseDMY(disbursementDate), product.tenureMonths * 30)) : '—';
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
const legalActionStatus = legalActionFor(stressed);
@@ -439,6 +695,8 @@ function generateAccounts(count: number): LoanAccount[] {
? fmt(latestRecoveryDate)
: formulaLastPaymentDate;
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
const seq = String(1000 + i).padStart(6, '0');
accounts.push({
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
@@ -449,6 +707,7 @@ function generateAccounts(count: number): LoanAccount[] {
productCode: product.code,
productName: product.name,
secured: product.secured,
repaymentType: product.repaymentType,
sanctionedAmount,
disbursementDate,
interestRate,
@@ -457,11 +716,16 @@ function generateAccounts(count: number): LoanAccount[] {
overdueDays,
overdueDaysQuarterAgo,
collateralValue,
collateralMarketValue,
emiAmount,
drawingPower,
maturityDate,
lastPaymentDate,
isLossAsset,
isRestructured,
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
recoveries,
collectionTimeline,
npaReason,
recoveryOfficer,
legalActionStatus
@@ -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<ReportGroupId, string> = {
reports: 'NPA Reports',
analysis: 'NPA Analysis',
classification: 'NPA Classification'
classification: 'NPA Classification',
'recovery-mgmt': 'Recovery Management'
};
export const DEFAULT_REPORT_ID = 'summary';
@@ -38,6 +38,9 @@
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
@@ -72,7 +75,7 @@
</div>
<div class="cctr-tabs">
@for (g of ['reports', 'analysis', 'classification']; track g) {
@for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
@@ -459,9 +462,14 @@
@if (selectedAccount; as a) {
<div class="pg-card">
<div class="pos-section-label">
{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
<div class="pos-section-label npa-detail-header">
<span
>{{ a.accountNo }} — {{ a.customerName }}
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span></span
>
<button type="button" class="pg-btn npa-timeline-trigger" (click)="openTimelineModal()">
<fa-icon icon="history"></fa-icon> Recovery Journey
</button>
</div>
<dl class="npa-detail-grid">
<div class="npa-detail-field">
@@ -781,7 +789,97 @@
</div>
}
<!-- ===================== 08 PROBABLE NPA ===================== -->
<!-- ===================== 08 UNDEBITED INTEREST RECOVERY REPORT ===================== -->
@if (activeReport === 'undebited-interest') {
<div class="pg-card">
<div class="pos-section-label">
Undebited Interest Recovery Report — Memorandum Interest Held Outside the Loan Account (IRAC Norms)
</div>
<p class="pg-sub npa-report-note">
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.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Stage</th>
<th>NPA Since</th>
<th class="num">Days NPA</th>
<th class="num">Rate</th>
<th class="num col-out">Undebited Interest Accrued</th>
<th class="num col-in">Interest Recovered (Cash)</th>
<th class="num">Undebited Interest Balance</th>
</tr>
</thead>
<tbody>
@for (a of paged(npaAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td>{{ npaSince(a) }}</td>
<td class="num">{{ daysSinceNpaOf(a) }}</td>
<td class="num">{{ a.interestRate }}%</td>
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">No NPA accounts in this branch.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="7">Total ({{ npaAccounts.length }} accounts)</td>
<td class="num">₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ interestRecoveredCashTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}</td>
</tr>
</tfoot>
</table>
</div>
@if (npaAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, npaAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(npaAccounts)"
(click)="goToPage(currentPage + 1, npaAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 09 PROBABLE NPA ===================== -->
@if (activeReport === 'probable') {
<div class="pg-card">
<div class="pos-section-label">
@@ -868,7 +966,7 @@
</div>
}
<!-- ===================== 09 SMA REPORT ===================== -->
<!-- ===================== 10 SMA REPORT ===================== -->
@if (activeReport === 'sma') {
<div class="pg-card">
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
@@ -944,6 +1042,548 @@
}
</div>
}
<!-- ===================== 11 RECOVERY MANAGEMENT DASHBOARD ===================== -->
@if (activeReport === 'recovery-dashboard') {
<div class="pg-card">
<div class="pos-section-label">Recovery Management Dashboard — Loan-Type Overdue Check</div>
<p class="pg-sub npa-report-note">
Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
servicing, and a Bullet loan (Gold) is checked once past maturity.
</p>
<div class="inv-kpis npa-kpis--three">
@for (row of recoveryTypeSummary; track row.type) {
<div class="inv-kpi">
<span class="inv-kpi__label">{{ row.label }}</span>
<span class="inv-kpi__value">{{ row.accounts }} accounts</span>
<span class="inv-kpi__sub" [class.npa-warn]="row.overdueCount > 0"
>{{ row.overdueCount }} overdue this cycle</span
>
</div>
}
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Expected vs Actual Recovery — This Cycle, by Loan Type</div>
<table class="pg-table">
<thead>
<tr>
<th>Loan Type</th>
<th class="num">Accounts</th>
<th class="num">Overdue Now</th>
<th class="num">Expected Recovery</th>
<th class="num col-in">Actual Recovery</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of recoveryTypeSummary; track row.type) {
<tr>
<td>{{ row.label }}</td>
<td class="num">{{ row.accounts }}</td>
<td class="num">{{ row.overdueCount }}</td>
<td class="num">₹{{ row.expected | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="row.actual - row.expected < 0">
₹{{ row.actual - row.expected | number: '1.0-0' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>Total</td>
<td class="num">{{ allAccounts.length }}</td>
<td class="num">
{{
recoveryTypeSummary[0].overdueCount +
recoveryTypeSummary[1].overdueCount +
recoveryTypeSummary[2].overdueCount
}}
</td>
<td class="num">₹{{ expectedRecoveryTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ actualRecoveryTotal | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVarianceTotal < 0">
₹{{ recoveryVarianceTotal | number: '1.0-0' }}
</td>
</tr>
</tfoot>
</table>
<p class="pg-sub npa-report-note">
Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues.
</p>
</div>
<div class="pg-card">
<div class="pos-section-label">Loan-wise Expected vs Actual Recovery</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Loan Type</th>
<th class="num">Expected</th>
<th class="num col-in">Actual</th>
<th class="num">Variance</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ repaymentTypeLabel(a.repaymentType) }}</td>
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ actualThisMonth(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-out]="recoveryVariance(a) < 0">
₹{{ recoveryVariance(a) | number: '1.0-0' }}
</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="recoveryVariance(a) >= 0"
[class.npa-badge--substandard]="recoveryVariance(a) < 0"
>
{{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (loanWiseRecoveryAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of
{{ loanWiseRecoveryAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, loanWiseRecoveryAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(loanWiseRecoveryAccounts)"
(click)="goToPage(currentPage + 1, loanWiseRecoveryAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 12 DAY-WISE OVERDUE REPORT ===================== -->
@if (activeReport === 'daywise-overdue') {
<div class="pg-card">
<div class="pos-section-label">Day-wise Overdue Report — Ageing Buckets</div>
<table class="pg-table">
<thead>
<tr>
<th>Overdue Bucket</th>
<th class="num">Accounts</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (b of overdueAgeingBuckets; track b.label) {
<tr>
<td>{{ b.label }}</td>
<td class="num">{{ b.count }}</td>
<td class="num">₹{{ b.outstanding | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ b.overdueAmt | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Overdue Accounts — Day-wise, Marked by SMA Stage</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Overdue Days</th>
<th class="num">Outstanding</th>
<th class="num col-out">Overdue Amount</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdueDaysAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">{{ a.overdueDays }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No overdue accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdueDaysAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of
{{ overdueDaysAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdueDaysAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdueDaysAccounts)"
(click)="goToPage(currentPage + 1, overdueDaysAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 13 DAY-WISE OVERDRAWN REPORT ===================== -->
@if (activeReport === 'daywise-overdrawn') {
<div class="pg-card">
<div class="pos-section-label">
Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts ({{ overdrawnBreachCount }} of
{{ overdrawnAccounts.length }} breaching Drawing Power)
</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Drawing Power</th>
<th class="num">Outstanding</th>
<th class="num col-out">Excess Over DP</th>
<th class="num">Days Overdrawn</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(overdrawnAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
<td class="num">{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!overdrawnBreachOf(a)"
[class.npa-badge--substandard]="overdrawnBreachOf(a)"
>
{{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Overdrawn accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (overdrawnAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of
{{ overdrawnAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, overdrawnAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(overdrawnAccounts)"
(click)="goToPage(currentPage + 1, overdrawnAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
@if (activeReport === 'agri-gold-value') {
<div class="pg-card">
<div class="pos-section-label">Agriculture / Gold Market Value Report — Current Security Value vs Loan</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Outstanding</th>
<th class="num">Collateral Value (At Sanction)</th>
<th class="num">Market Value (Now)</th>
<th class="num">Coverage %</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of paged(agriGoldAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralValue | number: '1.0-0' }}</td>
<td
class="num"
[class.col-in]="a.collateralMarketValue >= a.collateralValue"
[class.col-out]="a.collateralMarketValue < a.collateralValue"
>
₹{{ a.collateralMarketValue | number: '1.0-0' }}
</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!undersecuredOf(a)"
[class.npa-badge--substandard]="undersecuredOf(a)"
>
{{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No Agriculture / Gold accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (agriGoldAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of
{{ agriGoldAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, agriGoldAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(agriGoldAccounts)"
(click)="goToPage(currentPage + 1, agriGoldAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 15 EXCEPTION CHECK REPORT ===================== -->
@if (activeReport === 'exception-check') {
<div class="pg-card">
<div class="pos-section-label">
Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
</div>
<p class="pg-sub npa-report-note">
Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th>Stage</th>
<th class="num">Outstanding</th>
<th class="num">Market Value</th>
<th class="num col-out">Shortfall</th>
<th class="num">Coverage %</th>
</tr>
</thead>
<tbody>
@for (a of paged(exceptionAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td>{{ a.productName }}</td>
<td>
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num">₹{{ a.collateralMarketValue | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ securityShortfall(a) | number: '1.0-0' }}</td>
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No undersecured accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (exceptionAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of
{{ exceptionAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, exceptionAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(exceptionAccounts)"
(click)="goToPage(currentPage + 1, exceptionAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<!-- ===================== Recovery / Collection Journey popup ===================== -->
@if (showTimelineModal && selectedAccount; as a) {
<div class="npa-timeline-backdrop" (click)="closeTimelineModal()">
<div class="npa-timeline-modal" (click)="$event.stopPropagation()">
<header class="npa-timeline-modal__head">
<div class="npa-timeline-modal__title">
<span class="npa-timeline-modal__eyebrow">Recovery / Collection Journey</span>
<h2>{{ a.accountNo }} — {{ a.customerName }}</h2>
<div class="npa-timeline-modal__meta">
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
<span class="npa-timeline-modal__branch">{{ a.branchName }}</span>
<span class="npa-timeline-modal__count">{{ a.collectionTimeline.length }} touchpoints</span>
</div>
</div>
<button type="button" class="npa-timeline-modal__close" (click)="closeTimelineModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="npa-timeline-modal__body">
@if (a.collectionTimeline.length) {
<ol class="npa-timeline">
@for (e of a.collectionTimeline; track e.date + e.type) {
<li [class]="'npa-timeline-item npa-timeline-item--' + timelineEventTone(e.type)">
<span class="npa-timeline-item__icon"><fa-icon [icon]="timelineEventIcon(e.type)"></fa-icon></span>
<div class="npa-timeline-item__body">
<div class="npa-timeline-item__row">
<span class="npa-timeline-item__type">{{ e.type }}</span>
<span class="npa-timeline-item__date">{{ e.date }}</span>
</div>
<p class="npa-timeline-item__remarks">{{ e.remarks }}</p>
<span class="npa-timeline-item__channel">{{ e.channel }}</span>
</div>
</li>
}
</ol>
} @else {
<div class="npa-timeline-empty">
<fa-icon icon="check-circle"></fa-icon>
<p>No collection activity — this account has never gone overdue.</p>
</div>
}
</div>
</div>
</div>
}
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
File diff suppressed because it is too large Load Diff
+299
View File
@@ -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));
@@ -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<ReconGroupId, string> = {
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 = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
const between = (min: number, max: number): number => min + rng() * (max - min);
const round = (n: number): number => Math.round(n);
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;
}
@@ -0,0 +1,271 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">
RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }}
<span class="recon-scope-chip">{{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }}</span>
</div>
<h1 class="pg-title">{{ meta.label }}</h1>
<p class="pg-sub">
{{ meta.sideALabel }} vs {{ meta.sideBLabel }}
@if (meta.extraLabel) {
vs {{ meta.extraLabel }}
}
— as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (meta.scope === 'BRANCH') {
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
} @else {
<label class="pg-field">
<span>Scope</span>
<span class="pg-chip">Head Office (Whole Bank)</span>
</label>
}
<label class="pg-field">
<span>As On</span>
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
@if (!isRestricted) {
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="openExportModal()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
}
</div>
</header>
@if (!isRestricted) {
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideALabel }}</span>
<span class="inv-kpi__value">₹{{ totalA | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ items.length }} items</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideBLabel }}</span>
<span class="inv-kpi__value">₹{{ totalB | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ scopeName }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Absolute Difference</span>
<span class="inv-kpi__value" [class.npa-warn]="totalDiff > 0">₹{{ totalDiff | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ matchPct | number: '1.1-1' }}% matched</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Open Items</span>
<span class="inv-kpi__value npa-warn">{{ unmatchedCount + pendingCount }}</span>
<span class="inv-kpi__sub"
>{{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved</span
>
</div>
</div>
}
<div class="cctr-tabs">
@for (g of groups; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === g"
(click)="goToReport(groupOf(g)[0].id)"
>
{{ groupLabels[g] }}
</button>
}
</div>
<div class="cctr-tabs">
@for (m of groupOf(activeGroup); track m.id) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === m.id"
(click)="goToReport(m.id)"
>
{{ m.label }}
</button>
}
</div>
@if (isRestricted) {
<div class="pg-card">
<div class="recon-restricted">
<fa-icon icon="university" class="recon-restricted__icon"></fa-icon>
<div class="recon-restricted__title">Managed Centrally by Head Office</div>
<p class="recon-restricted__body">
{{ 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.
</p>
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">{{ meta.label }} — {{ scopeName }}</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>{{ meta.referenceLabel }}</th>
<th>Description</th>
@if (meta.scope === 'BRANCH') {
<th>Branch</th>
}
<th>Date</th>
<th class="num">{{ meta.sideALabel }}</th>
<th class="num">{{ meta.sideBLabel }}</th>
@if (meta.extraLabel) {
<th class="num">{{ meta.extraLabel }}</th>
}
<th class="num">Difference</th>
<th>Status</th>
<th>Remarks / Action</th>
</tr>
</thead>
<tbody>
@for (item of paged(items); track item.id) {
<tr>
<td>
<span class="pg-chip">{{ item.reference }}</span>
</td>
<td>{{ item.description }}</td>
@if (meta.scope === 'BRANCH') {
<td>{{ item.branchName }}</td>
}
<td>{{ item.date }}</td>
<td class="num">₹{{ item.sideAAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ item.sideBAmount | number: '1.0-0' }}</td>
@if (meta.extraLabel) {
<td class="num">₹{{ item.extraAmount | number: '1.0-0' }}</td>
}
<td class="num" [class.col-out]="diff(item) !== 0" [class.col-in]="diff(item) === 0">
₹{{ diff(item) | number: '1.0-0' }}
</td>
<td>
<span [class]="badgeClass(item.status)">{{ item.status }}</span>
</td>
<td>
@if (isItemOpen(item)) {
<div class="recon-action-row">
<input
type="text"
class="recon-remark-input"
[(ngModel)]="remarksDraft[item.id]"
placeholder="Remarks (optional)"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="markReconciled(item)">
Mark as Reconciled
</button>
</div>
} @else if (item.status === 'RESOLVED') {
<span class="pg-sub">{{ item.remarks }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td
[attr.colspan]="meta.scope === 'BRANCH' ? (meta.extraLabel ? 10 : 9) : meta.extraLabel ? 9 : 8"
class="pg-empty"
>
No {{ meta.label }} items for this branch.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (items.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, items)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(items) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(items)"
(click)="goToPage(currentPage + 1, items)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
@if (showExportModal) {
<div class="recon-modal-backdrop" (click)="closeExportModal()">
<div class="recon-modal" (click)="$event.stopPropagation()">
<div class="recon-modal__header">
<span>Export to Excel — Sign-off (Optional)</span>
<button type="button" class="recon-modal__close" (click)="closeExportModal()" aria-label="Close">
&times;
</button>
</div>
<p class="pg-sub" style="padding: 0 20px">
Fill in Prepared / Checked / Approved details if this copy needs them for the record — or leave them blank and
download straight away.
</p>
<div class="recon-signoff-grid">
<div class="recon-signoff-col">
<span class="recon-signoff-role">Prepared By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.preparedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.preparedByDesg" />
</div>
<div class="recon-signoff-col">
<span class="recon-signoff-role">Checked By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.checkedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.checkedByDesg" />
</div>
<div class="recon-signoff-col">
<span class="recon-signoff-role">Approved By</span>
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.approvedByName" />
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.approvedByDesg" />
</div>
</div>
<div class="inv-form-actions" style="padding: 0 20px 20px">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeExportModal()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="confirmExport()">Download</button>
</div>
</div>
</div>
}
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -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<string, string> = {};
generating = false;
showExportModal = false;
exportSignOff: ReconExportSignOff = blankExportSignOff();
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private sub?: Subscription;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
this.sub = this.route.paramMap.subscribe((params) => {
const id = params.get('reportId') || DEFAULT_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<T>(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<void> {
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);
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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 {}