Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo
This commit is contained in:
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
-16
@@ -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>
|
||||
|
||||
+65
@@ -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;
|
||||
}
|
||||
|
||||
+233
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & 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&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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
×
|
||||
</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 {}
|
||||
Reference in New Issue
Block a user