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:
@@ -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">
|
||||
×
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user