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()">
|
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -209,43 +212,61 @@
|
|||||||
? 'Clearing'
|
? 'Clearing'
|
||||||
: 'Digital'
|
: 'Digital'
|
||||||
}}
|
}}
|
||||||
— Transaction Details
|
— Transaction Details (Full Double Entry)
|
||||||
@if (glFilter !== null) {
|
@if (glFilter !== null) {
|
||||||
· {{ glFilter }}
|
· {{ glFilter }}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table cctr-journal">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Voucher / Ref No</th>
|
<th>Voucher / Ref No</th>
|
||||||
<th>Account No</th>
|
<th>Dr / Cr</th>
|
||||||
<th>Account Name / Reference</th>
|
<th>GL / Account</th>
|
||||||
<th>Particulars</th>
|
|
||||||
<th class="num col-out">Debit</th>
|
<th class="num col-out">Debit</th>
|
||||||
<th class="num col-in">Credit</th>
|
<th class="num col-in">Credit</th>
|
||||||
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (t of filteredTxns; track t.voucherNo) {
|
@for (t of filteredTxns; track t.voucherNo) {
|
||||||
<tr>
|
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||||
<td>{{ asOnDate }}</td>
|
<td>{{ asOnDate }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ display(t.accountNo) }}</td>
|
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||||
<td>{{ t.accountName }}</td>
|
<td>
|
||||||
<td>{{ t.particulars }}</td>
|
|
||||||
<td class="num col-out">
|
|
||||||
@if (t.debit > 0) {
|
@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>
|
||||||
<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) {
|
@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>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
@@ -255,9 +276,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="cctr-total-row">
|
<tr class="cctr-total-row">
|
||||||
<td colspan="5">Total</td>
|
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||||
<td class="num">₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}</td>
|
<td class="num">
|
||||||
<td class="num">₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}</td>
|
₹{{ 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>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+65
@@ -71,3 +71,68 @@
|
|||||||
background: #eff6ff;
|
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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
||||||
|
|
||||||
interface SummaryRow {
|
interface SummaryRow {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
showDetails = false;
|
showDetails = false;
|
||||||
/** GL/channel filter for the Transaction Details view — null means "All". */
|
/** GL/channel filter for the Transaction Details view — null means "All". */
|
||||||
glFilter: string | null = null;
|
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). */
|
/** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */
|
||||||
isHeadOffice = false;
|
isHeadOffice = false;
|
||||||
@@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
return this.activeTab === 'digital' ? row.code : row.name;
|
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. */
|
/** 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 {
|
rowCredit(row: SummaryRow, txns: TxnRow[]): number {
|
||||||
return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0);
|
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 };
|
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