feat(cashier): full double-entry journal view + Excel export for Cash/Clearing/Transfer Summary

Transaction Details now renders each voucher as a paired Dr/Cr journal entry with a
derived contra account and a Balanced status chip, so both sides of a transaction are
visible at a glance for reconciliation. Adds a shared cashier-export.ts (mirrors the
Investment module's exporter) and an Export to Excel button producing a Summary +
Transaction Details workbook, plus a Branch Breakup sheet for Head Office consolidated view.
This commit is contained in:
2026-08-01 20:32:31 +05:30
parent 0832017508
commit 1317a93838
4 changed files with 428 additions and 16 deletions
@@ -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;
}
}
}