diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html index b4656e131..f7e1ec982 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.html @@ -31,6 +31,9 @@ + @@ -209,43 +212,61 @@ ? 'Clearing' : 'Digital' }} - — Transaction Details + — Transaction Details (Full Double Entry) @if (glFilter !== null) { · {{ glFilter }} } - +
- - - + + + @for (t of filteredTxns; track t.voucherNo) { - + - - - - + - + + + + + + + + + + } @empty { @@ -255,9 +276,14 @@ - - - + + + +
Date Voucher / Ref NoAccount NoAccount Name / ReferenceParticularsDr / CrGL / Account Debit CreditStatus
{{ asOnDate }} {{ display(t.voucherNo) }} {{ display(t.accountNo) }}{{ t.accountName }}{{ t.particulars }} + Dr @if (t.debit > 0) { - ₹{{ t.debit | number: '1.2-2' }} + {{ t.glCode }} {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{ + t.accountName + }}) + } @else { + {{ contraLabel(t) }} } + ₹{{ legAmount(t) | number: '1.2-2' }} + ✓ Balanced +
Cr @if (t.credit > 0) { - ₹{{ t.credit | number: '1.2-2' }} + {{ t.glCode }} {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{ + t.accountName + }}) + } @else { + {{ contraLabel(t) }} } ₹{{ legAmount(t) | number: '1.2-2' }}
Total₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}Total ({{ filteredTxns.length }} vouchers) + ₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }} + + ₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }} +
diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss index e6aa08e9f..19552a68d 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss @@ -71,3 +71,68 @@ background: #eff6ff; } } + +// Both journal lines of one voucher share a tint and sit flush against each other (no dotted +// divider between them) so the pair reads as a single entry; a hairline separates one voucher's +// pair from the next. +.cctr-journal-row { + &--first td { + border-bottom: none; + padding-bottom: 4px; + } + + &--second td { + border-top: none; + padding-top: 4px; + border-bottom: 1px solid #e2e8f0; + } + + &--first, + &--second { + &:hover td { + background: #f8fafc; + } + } +} + +.cctr-drcr { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + padding: 1px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; + + &--dr { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + } + + &--cr { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + } +} + +.cctr-contra { + color: #64748b; + font-style: italic; +} + +.cctr-balanced-chip { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; + white-space: nowrap; +} diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts index 0399b5d66..65fbf5d25 100644 --- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts +++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { ExportSheetSpec, exportWorkbook } from '../cashier-export'; interface SummaryRow { code: string; @@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent { showDetails = false; /** GL/channel filter for the Transaction Details view — null means "All". */ glFilter: string | null = null; + /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */ + generating = false; + + private readonly tabLabels: Record = { + cash: 'Cash', + transfer: 'Transfer', + clearing: 'Clearing', + digital: 'Digital' + }; /** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */ isHeadOffice = false; @@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent { return this.activeTab === 'digital' ? row.code : row.name; } + /** GL name for the code tracked on this txn row, so the Transaction Details journal can show + * "which GL" alongside the account, not just a bare code. */ + glName(code: string): string { + return this.rowsForActiveTab.find((r) => r.code === code)?.name || code; + } + + /** + * Every transaction here is only ever recorded as ONE leg (whichever GL this Cash/Clearing/ + * Transfer/Digital screen tracks) — the offsetting leg (the customer's own account, a + * settlement/clearing-house account, etc.) was never modelled as a separate GL row. Rather + * than leaving that side blank, this derives a sensible label for it so the Transaction + * Details view can show the FULL double entry (Dr leg + Cr leg, always equal amounts) — + * exactly what a real CBS "Day Book"/Journal Register shows, and what makes it possible to + * see at a glance that a transaction is complete and ready for reconciliation. + * + * For internal cash-movement postings (ATM cash-in-transit, currency chest, denomination + * shortages, CTS grid rounding) the account name already describes the SAME leg rather than a + * distinct counter-party, so those get a purpose-built contra label. Everything else already + * has a descriptive counter-party name (customer, vendor, bank branch, government department) + * that IS the natural contra account, so it's used as-is — WITHOUT repeating the account/ + * reference number, since that number is already shown against the tracked GL leg on the + * other line; showing it a second time here would make both lines look like the same account, + * defeating the point of a "Balanced" two-sided entry. + */ + contraLabel(t: TxnRow): string { + const p = t.particulars.toLowerCase(); + if (p.includes('atm') && (p.includes('vault') || p.includes('loading') || p.includes('surrendered'))) { + return 'ATM Cash-in-Transit Account'; + } + if (p.includes('currency chest')) { + return 'Currency Chest Account (Sponsor Bank)'; + } + if (p.includes('denomination') || p.includes('shortage adjustment')) { + return 'Cash Difference / Shortage Suspense Account'; + } + if (p.includes('cts grid rounding')) { + return 'CTS Grid Rounding Suspense Account'; + } + return t.accountName; + } + + /** The single non-zero amount on this leg — always what the derived contra leg mirrors. */ + legAmount(t: TxnRow): number { + return t.debit > 0 ? t.debit : t.credit; + } + /** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */ rowCredit(row: SummaryRow, txns: TxnRow[]): number { return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0); @@ -1566,4 +1622,181 @@ export class CashClearingTransferSummaryComponent { return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit }; }); } + + /** + * Exports the full report for the currently viewed scope (own branch, a drilled-into branch, + * or "All Branches" consolidated) — every category (Cash/Transfer/Clearing/Digital), not just + * the tab on screen, so the workbook is a complete record. "Summary" mirrors the Opening/ + * Credit/Debit/Closing view; "Transaction Details" mirrors the full double-entry journal view + * (same glName/contraLabel/legAmount derivations, so the export can never disagree with what's + * on screen); "Branch Breakup" is added only when Head Office is viewing the consolidated view. + */ + async exportExcel(): Promise { + this.generating = true; + try { + const summaryRows: (string | number | null)[][] = []; + const journalRows: (string | number | null)[][] = []; + + for (const tab of this.allTabs) { + const label = this.tabLabels[tab]; + const rows = this.effectiveRows(tab); + const txns = this.effectiveTxns(tab); + + rows.forEach((r) => { + summaryRows.push([ + label, + r.code, + r.name, + r.opening, + this.rowCredit(r, txns), + this.rowDebit(r, txns), + this.closing(r, txns) + ]); + }); + summaryRows.push([ + `${label} Total`, + '', + '', + this.total(rows, txns, 'opening'), + this.total(rows, txns, 'credit'), + this.total(rows, txns, 'debit'), + this.totalClosing(rows, txns) + ]); + + txns.forEach((t) => { + const glText = rows.find((r) => r.code === t.glCode)?.name || t.glCode; + journalRows.push([ + label, + this.asOnDate, + this.display(t.voucherNo), + 'Dr', + t.debit > 0 ? t.glCode : '', + t.debit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t), + this.legAmount(t), + null, + 'Balanced' + ]); + journalRows.push([ + label, + this.asOnDate, + this.display(t.voucherNo), + 'Cr', + t.credit > 0 ? t.glCode : '', + t.credit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t), + null, + this.legAmount(t), + 'Balanced' + ]); + }); + } + summaryRows.push([ + 'Grand Total', + '', + '', + this.grandOpening, + this.grandCredit, + this.grandDebit, + this.grandClosing + ]); + + const sheets: ExportSheetSpec[] = [ + { + sheetName: 'Summary', + title: 'Cash / Clearing / Transfer - Summary Report', + subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`, + headers: [ + 'Category', + 'GL Code', + 'Particulars', + 'Opening', + 'Credit', + 'Debit', + 'Closing' + ], + widths: [ + 12, + 12, + 34, + 16, + 16, + 16, + 16 + ], + rows: summaryRows + }, + { + sheetName: 'Transaction Details', + title: 'Transaction Details — Full Double Entry', + subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`, + headers: [ + 'Category', + 'Date', + 'Voucher / Ref No', + 'Dr / Cr', + 'GL Code', + 'GL / Account', + 'Debit', + 'Credit', + 'Status' + ], + widths: [ + 12, + 12, + 20, + 8, + 10, + 46, + 16, + 16, + 12 + ], + rows: journalRows + } + ]; + + if (this.isHeadOffice && this.selectedBranchCode === 'ALL') { + sheets.push({ + sheetName: 'Branch Breakup', + title: 'Branch-wise Break-up', + subtitle: `As On: ${this.asOnDate}`, + headers: [ + 'Branch', + 'Opening', + 'Credit', + 'Debit', + 'Closing' + ], + widths: [ + 22, + 16, + 16, + 16, + 16 + ], + rows: [ + ...this.branchBreakup.map((b) => [ + b.name, + b.opening, + b.credit, + b.debit, + b.closing + ]), + [ + 'All Branches', + this.grandOpening, + this.grandCredit, + this.grandDebit, + this.grandClosing + ] + ] + }); + } + + await exportWorkbook(sheets, `Cash-Clearing-Transfer-Summary-${this.scopeName}-${this.asOnDate}`); + } catch (err) { + console.error('Cash/Clearing/Transfer export failed', err); + } finally { + this.generating = false; + } + } } diff --git a/src/app/cashier/cashier-export.ts b/src/app/cashier/cashier-export.ts new file mode 100644 index 000000000..813e33c2b --- /dev/null +++ b/src/app/cashier/cashier-export.ts @@ -0,0 +1,88 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts) +// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet +// names, Blob download) is written once and every cashier report's export behaves the same way. + +import * as ExcelJS from 'exceljs'; + +export interface ExportSheetSpec { + /** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */ + sheetName: string; + /** Bold title row, merged across all columns. */ + title: string; + /** Optional italic subtitle row (e.g. scope/date), merged across all columns. */ + subtitle?: string; + headers: string[]; + rows: (string | number | null)[][]; + widths?: number[]; +} + +/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */ +function safeSheetName(name: string): string { + return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31); +} + +function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void { + const sheet = wb.addWorksheet(safeSheetName(spec.sheetName)); + sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w })); + + const title = sheet.addRow([spec.title]); + sheet.mergeCells(title.number, 1, title.number, spec.headers.length); + title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } }; + title.alignment = { horizontal: 'center', vertical: 'middle' }; + title.height = 24; + title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } }; + + if (spec.subtitle) { + const meta = sheet.addRow([spec.subtitle]); + sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length); + meta.font = { italic: true, size: 10 }; + } + sheet.addRow([]); + + const head = sheet.addRow(spec.headers); + head.font = { bold: true }; + head.alignment = { wrapText: true, horizontal: 'center' }; + head.eachCell((c) => { + c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; + c.border = { bottom: { style: 'thin' } }; + }); + + for (const r of spec.rows) { + const row = sheet.addRow(r); + r.forEach((v, idx) => { + if (typeof v === 'number') { + row.getCell(idx + 1).numFmt = '#,##0.00'; + } + }); + } + sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }]; +} + +/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */ +export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBSNexora'; + wb.created = new Date(); + sheets.forEach((s) => buildSheet(wb, s)); + + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}