feat(investment,reconciliation): add Export to Excel across Investment, fix export popup

Adds a shared Export to Excel utility (investment-export.ts) and wires it
into every listing/report screen in the Investment module — Dashboard
(break-up + allocation), Reports (all ~20 report shapes), Interest (per
active tab), the FD/Bond/Share Holdings registers, and Masters (per active
master tab). Entry/transaction forms intentionally have no export button.
No sign-off is included here, matching the request to keep that feature
reconciliation-only.

Also fixes the Reconciliation export flow: the Prepared/Checked/Approved
sign-off is now an optional one-off popup shown when "Export to Excel" is
clicked (fields are blank by default and downloading works whether or not
they're filled), replacing the earlier always-visible sign-off card. Also
fixes three reconciliation reports (Sundry Dr/Cr, NEFT/RTGS, UDIR) whose
labels contain "/" — an Excel-forbidden worksheet-name character — which
was silently aborting their export.
This commit is contained in:
2026-08-01 19:20:53 +05:30
parent 8ac4010967
commit 0832017508
18 changed files with 1610 additions and 0 deletions
@@ -51,6 +51,11 @@
<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>
@@ -225,4 +230,42 @@
}
</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">
&times;
</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>
@@ -11,6 +11,7 @@ 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,
@@ -33,6 +34,28 @@ import {
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
@@ -88,6 +111,10 @@ export class ReconciliationReportComponent implements OnInit, OnDestroy {
remarksDraft: Record<string, string> = {};
generating = false;
showExportModal = false;
exportSignOff: ReconExportSignOff = blankExportSignOff();
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
@@ -235,6 +262,151 @@ export class ReconciliationReportComponent implements OnInit, OnDestroy {
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;
@@ -79,6 +79,88 @@
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;