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:
+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);
|
||||
}
|
||||
Reference in New Issue
Block a user