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:
@@ -25,6 +25,11 @@
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (activeTab === 'holdings') {
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
yearsBetween,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
|
||||
type BondTab =
|
||||
@@ -210,6 +211,7 @@ export class BondInvestmentComponent {
|
||||
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
||||
readonly authorityOptions = AUTHORITY_OPTIONS;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -1092,6 +1094,68 @@ export class BondInvestmentComponent {
|
||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel (Holdings register) --------------------
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: this.instrumentLabel(this.bondType),
|
||||
title: `${this.instrumentLabel(this.bondType)} Holdings Register`,
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Party',
|
||||
'Units',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Purchase Date',
|
||||
'Maturity Date',
|
||||
'Accrued Interest',
|
||||
'Maturity Value',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
26,
|
||||
22,
|
||||
12,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
rows: this.holdingsForType.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
h.party,
|
||||
h.units,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.purchaseDate,
|
||||
h.maturityDate,
|
||||
this.accrued(h),
|
||||
this.maturity(h),
|
||||
h.status
|
||||
])
|
||||
}
|
||||
],
|
||||
`${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}`
|
||||
);
|
||||
this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`);
|
||||
} catch (err) {
|
||||
console.error('Bond Holdings 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;
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
Part-Payment entries.
|
||||
</p>
|
||||
</div>
|
||||
@if (activeTab === 'holdings') {
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
addPeriod,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
|
||||
|
||||
@@ -103,6 +104,7 @@ export class FdInvestmentComponent {
|
||||
'BRANCH 2'
|
||||
];
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -450,6 +452,68 @@ export class FdInvestmentComponent {
|
||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel (Holdings register) --------------------
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'FD Holdings',
|
||||
title: 'FD Holdings Register',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Purchase Date',
|
||||
'Maturity Date',
|
||||
'Accrued Interest',
|
||||
'Maturity Value',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
28,
|
||||
18,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
14,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
rows: this.holdings.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout',
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
h.purchaseDate,
|
||||
h.maturityDate,
|
||||
this.accrued(h),
|
||||
this.maturity(h),
|
||||
h.status
|
||||
])
|
||||
}
|
||||
],
|
||||
`FD-Holdings-${this.asOnDate}`
|
||||
);
|
||||
this.toast('FD Holdings exported.');
|
||||
} catch (err) {
|
||||
console.error('FD Holdings 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;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="inv-kpis">
|
||||
@@ -196,3 +201,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
daysToMaturity,
|
||||
maturityAmount
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
interface InstrumentSummary {
|
||||
type: InstrumentType;
|
||||
@@ -57,6 +58,10 @@ interface AllocationRow {
|
||||
})
|
||||
export class InvestmentDashboardComponent {
|
||||
asOnDate = '';
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
||||
{ type: 'FD', route: '/investment/fd' },
|
||||
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
||||
@@ -152,4 +157,85 @@ export class InvestmentDashboardComponent {
|
||||
instrumentLabel(type: InstrumentType): string {
|
||||
return INSTRUMENT_LABELS[type];
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Instrument Breakup',
|
||||
title: 'Investment Portfolio — Instrument-wise Break-up',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Instrument Type',
|
||||
'Holdings',
|
||||
'Face Value',
|
||||
'Accrued Interest',
|
||||
'Maturity Value'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
12,
|
||||
18,
|
||||
18,
|
||||
18
|
||||
],
|
||||
rows: this.summaries.map((s) => [
|
||||
s.label,
|
||||
s.count,
|
||||
s.faceValue,
|
||||
s.accruedInterest,
|
||||
s.maturityValue
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Portfolio Allocation',
|
||||
title: 'Portfolio Allocation — Target vs Actual',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Instrument Type',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Actual Value',
|
||||
'Variance %',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
12,
|
||||
18
|
||||
],
|
||||
rows: this.allocationVsTarget.map((a) => [
|
||||
a.label,
|
||||
a.targetPct,
|
||||
a.actualPct,
|
||||
a.actualValue,
|
||||
a.variancePct,
|
||||
a.statusLabel
|
||||
])
|
||||
}
|
||||
],
|
||||
`Investment-Dashboard-${this.asOnDate}`
|
||||
);
|
||||
this.toast('Investment Dashboard exported.');
|
||||
} catch (err) {
|
||||
console.error('Investment Dashboard 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;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 every listing/report screen in the Investment module
|
||||
// (Dashboard, Reports, Interest, Holdings, Masters) — centralised so the ExcelJS boilerplate
|
||||
// (title/meta/header rows, number formatting, safe sheet names, Blob download) is written once
|
||||
// and every screen's export always behaves and looks the same.
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +16,11 @@
|
||||
Government Bond Interest, FD Interest and FD registers.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
suspenseInterest,
|
||||
provisionRequiredInv
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type InterestTab =
|
||||
| 'receivable'
|
||||
@@ -86,6 +87,7 @@ export class InvestmentInterestComponent {
|
||||
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
|
||||
receiveDrafts: Record<string, number | null> = {};
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -390,6 +392,297 @@ export class InvestmentInterestComponent {
|
||||
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
|
||||
return [
|
||||
t.txnDate,
|
||||
t.voucherNo,
|
||||
t.certNo,
|
||||
t.instrumentType,
|
||||
t.particulars,
|
||||
t.amount
|
||||
];
|
||||
}
|
||||
|
||||
private exportSheets(): ExportSheetSpec[] {
|
||||
const subtitle = `As on ${this.asOnDate}`;
|
||||
const txnHeaders = [
|
||||
'Date',
|
||||
'Voucher No',
|
||||
'Cert No',
|
||||
'Type',
|
||||
'Particulars',
|
||||
'Amount'
|
||||
];
|
||||
const txnWidths = [
|
||||
14,
|
||||
22,
|
||||
14,
|
||||
14,
|
||||
42,
|
||||
16
|
||||
];
|
||||
const one = (
|
||||
sheetName: string,
|
||||
title: string,
|
||||
headers: string[],
|
||||
widths: number[],
|
||||
rows: (string | number | null)[][]
|
||||
): ExportSheetSpec[] => [
|
||||
{ sheetName, title, subtitle, headers, widths, rows }];
|
||||
|
||||
switch (this.activeTab) {
|
||||
case 'receivable': {
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Interest Receivable',
|
||||
title: 'Interest Receivable — Posted but Not Yet Collected',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
'Type',
|
||||
'Status',
|
||||
'Interest Posted',
|
||||
'Interest Received',
|
||||
'Receivable Balance'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.receivableRows.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName || h.party,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
this.provisionLabel(h),
|
||||
this.posted(h),
|
||||
this.received(h),
|
||||
this.receivableBalance(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
if (this.suspenseRows.length > 0) {
|
||||
sheets.push({
|
||||
sheetName: 'NPI Suspense',
|
||||
title: 'Non-Performing Investments — Interest Held in Suspense',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
'Type',
|
||||
'Status',
|
||||
'Provision Required',
|
||||
'Interest in Suspense'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.suspenseRows.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName || h.party,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
this.provisionLabel(h),
|
||||
this.provisionRequired(h),
|
||||
this.suspense(h)
|
||||
])
|
||||
});
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
case 'fdCalc':
|
||||
return one(
|
||||
'FD Interest Calc',
|
||||
'FD Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.fdCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.fdInterest(h)
|
||||
])
|
||||
);
|
||||
case 'bondCalc':
|
||||
return one(
|
||||
'Bond Interest Calc',
|
||||
'Govt./Bond Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.bondCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.bondInterest(h)
|
||||
])
|
||||
);
|
||||
case 'receive': {
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Receive Targets',
|
||||
title: 'Interest Receive — Outstanding for Collection',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
'Type',
|
||||
'Status',
|
||||
'Outstanding'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
16
|
||||
],
|
||||
rows: this.receiveTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName || h.party,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
this.provisionLabel(h),
|
||||
this.outstandingFor(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
if (this.receivedLog.length > 0) {
|
||||
sheets.push({
|
||||
sheetName: 'Received Log',
|
||||
title: 'Interest Received Log',
|
||||
subtitle,
|
||||
headers: txnHeaders,
|
||||
widths: txnWidths,
|
||||
rows: this.receivedLog.map((t) => this.txnExportRow(t))
|
||||
});
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
case 'bondIntRegister':
|
||||
return one(
|
||||
'Govt Bond Int Register',
|
||||
'Government Bond Interest Register',
|
||||
txnHeaders,
|
||||
txnWidths,
|
||||
this.bondIntRegister.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'fdIntRegister':
|
||||
return one(
|
||||
'FD Interest Register',
|
||||
'FD Interest Register',
|
||||
txnHeaders,
|
||||
txnWidths,
|
||||
this.fdIntRegister.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'fdRegister':
|
||||
return one(
|
||||
'FD Register',
|
||||
'FD Register',
|
||||
[
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Sub Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Deposit Date',
|
||||
'Maturity Date',
|
||||
'Days Left',
|
||||
'Accrued Interest',
|
||||
'Maturity Value',
|
||||
'Status'
|
||||
],
|
||||
[
|
||||
14,
|
||||
26,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
14,
|
||||
14,
|
||||
12,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
this.fdRegister.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.subType,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
h.purchaseDate,
|
||||
h.maturityDate,
|
||||
this.daysLeft(h),
|
||||
this.accrued(h),
|
||||
this.maturity(h),
|
||||
h.status
|
||||
])
|
||||
);
|
||||
default:
|
||||
return one('Interest', 'Investment Interest', [], [], []);
|
||||
}
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(this.exportSheets(), `Investment-Interest-${this.activeTab}-${this.asOnDate}`);
|
||||
this.toast('Exported.');
|
||||
} catch (err) {
|
||||
console.error('Investment Interest 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;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
Market Detail masters.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
LienReason,
|
||||
InstrumentType
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type MasterTab =
|
||||
| 'party'
|
||||
@@ -85,6 +86,20 @@ export class InvestmentMastersComponent {
|
||||
'SHARE'
|
||||
];
|
||||
|
||||
private readonly masterLabels: Record<MasterTab, string> = {
|
||||
party: 'Bank / Party Master',
|
||||
script: 'Script Master',
|
||||
subtype: 'SubType Master',
|
||||
broker: 'Broker Master',
|
||||
control: 'Control Parameter',
|
||||
dailyscript: 'Daily Script Master',
|
||||
lien: 'LIEN Reason Master',
|
||||
mfproduct: 'Mutual Fund Product Master',
|
||||
sglbank: 'SGL Bank Master',
|
||||
marketdetail: 'Script Market Detail'
|
||||
};
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -251,6 +266,244 @@ export class InvestmentMastersComponent {
|
||||
this.toast('Daily script rate added.');
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
private exportSheets(): ExportSheetSpec[] {
|
||||
const title = this.masterLabels[this.activeTab];
|
||||
const one = (headers: string[], widths: number[], rows: (string | number | null)[][]): ExportSheetSpec[] => [
|
||||
{ sheetName: title, title, headers, widths, rows }];
|
||||
|
||||
switch (this.activeTab) {
|
||||
case 'party':
|
||||
return one(
|
||||
[
|
||||
'Party Code',
|
||||
'Party Name',
|
||||
'Type',
|
||||
'PAN No'
|
||||
],
|
||||
[
|
||||
12,
|
||||
30,
|
||||
16,
|
||||
14
|
||||
],
|
||||
this.parties.map((p) => [
|
||||
p.partyCode,
|
||||
p.partyName,
|
||||
p.partyType,
|
||||
p.panNo
|
||||
])
|
||||
);
|
||||
case 'script':
|
||||
return one(
|
||||
[
|
||||
'Script Code',
|
||||
'Script Name',
|
||||
'Instrument',
|
||||
'ISIN',
|
||||
'Face Value/Unit'
|
||||
],
|
||||
[
|
||||
16,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
16
|
||||
],
|
||||
this.scripts.map((s) => [
|
||||
s.scriptCode,
|
||||
s.scriptName,
|
||||
s.instrumentType,
|
||||
s.isin,
|
||||
s.faceValuePerUnit
|
||||
])
|
||||
);
|
||||
case 'subtype':
|
||||
return one(
|
||||
[
|
||||
'Code',
|
||||
'Sub Type',
|
||||
'Instrument'
|
||||
],
|
||||
[
|
||||
10,
|
||||
24,
|
||||
16
|
||||
],
|
||||
this.subTypes.map((s) => [
|
||||
s.subTypeCode,
|
||||
s.subTypeName,
|
||||
s.instrumentType
|
||||
])
|
||||
);
|
||||
case 'broker':
|
||||
return one(
|
||||
[
|
||||
'Broker Code',
|
||||
'Broker Name',
|
||||
'Contact',
|
||||
'Empanelled On',
|
||||
'Status'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
],
|
||||
this.brokers.map((b) => [
|
||||
b.brokerCode,
|
||||
b.brokerName,
|
||||
b.contactNo,
|
||||
b.empanelledOn,
|
||||
b.status
|
||||
])
|
||||
);
|
||||
case 'control':
|
||||
return one(
|
||||
[
|
||||
'Parameter',
|
||||
'Value',
|
||||
'Description'
|
||||
],
|
||||
[
|
||||
26,
|
||||
14,
|
||||
40
|
||||
],
|
||||
this.controlParams.map((c) => [
|
||||
c.paramName,
|
||||
c.paramValue,
|
||||
c.description
|
||||
])
|
||||
);
|
||||
case 'dailyscript':
|
||||
return one(
|
||||
[
|
||||
'Rate Date',
|
||||
'Script Code',
|
||||
'Script Name',
|
||||
'Open',
|
||||
'High',
|
||||
'Low',
|
||||
'Close'
|
||||
],
|
||||
[
|
||||
14,
|
||||
16,
|
||||
26,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
12
|
||||
],
|
||||
this.dailyScriptRates.map((r) => [
|
||||
r.rateDate,
|
||||
r.scriptCode,
|
||||
r.scriptName,
|
||||
r.openRate,
|
||||
r.highRate,
|
||||
r.lowRate,
|
||||
r.closeRate
|
||||
])
|
||||
);
|
||||
case 'lien':
|
||||
return one(
|
||||
[
|
||||
'Reason Code',
|
||||
'Description'
|
||||
],
|
||||
[
|
||||
14,
|
||||
40
|
||||
],
|
||||
this.lienReasons.map((l) => [
|
||||
l.reasonCode,
|
||||
l.reasonDescription
|
||||
])
|
||||
);
|
||||
case 'mfproduct':
|
||||
return one(
|
||||
[
|
||||
'Product Code',
|
||||
'Product Name',
|
||||
'AMC',
|
||||
'Category',
|
||||
'Risk'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
24,
|
||||
20,
|
||||
12
|
||||
],
|
||||
this.mfProducts.map((m) => [
|
||||
m.productCode,
|
||||
m.productName,
|
||||
m.amcName,
|
||||
m.category,
|
||||
m.riskLevel
|
||||
])
|
||||
);
|
||||
case 'sglbank':
|
||||
return one(
|
||||
[
|
||||
'SGL Code',
|
||||
'Bank Name',
|
||||
'Branch'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
24
|
||||
],
|
||||
this.sglBanks.map((s) => [
|
||||
s.sglCode,
|
||||
s.bankName,
|
||||
s.branch
|
||||
])
|
||||
);
|
||||
case 'marketdetail':
|
||||
return one(
|
||||
[
|
||||
'Script Code',
|
||||
'Market Price',
|
||||
'As On',
|
||||
'YTM %'
|
||||
],
|
||||
[
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
12
|
||||
],
|
||||
this.marketDetails.map((m) => [
|
||||
m.scriptCode,
|
||||
m.marketPrice,
|
||||
m.asOnDate,
|
||||
m.ytm
|
||||
])
|
||||
);
|
||||
default:
|
||||
return one([], [], []);
|
||||
}
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(this.exportSheets(), `Investment-${this.masterLabels[this.activeTab]}`);
|
||||
this.toast(`${this.masterLabels[this.activeTab]} exported.`);
|
||||
} catch (err) {
|
||||
console.error('Investment Masters 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;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
same holdings/transactions used by the Entry screens.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
@@ -692,3 +697,5 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
daysToMaturity,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
||||
|
||||
@@ -50,6 +51,11 @@ export class InvestmentReportsComponent {
|
||||
activeReport = 'govtSecBalance';
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
setGroup(group: ReportGroup, defaultReport: string): void {
|
||||
this.activeGroup = group;
|
||||
this.activeReport = defaultReport;
|
||||
@@ -244,4 +250,364 @@ export class InvestmentReportsComponent {
|
||||
get portfolioTotalMaturity(): number {
|
||||
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0);
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
private readonly holdingsExportHeaders = [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
'Sub Type',
|
||||
'Units',
|
||||
'Face Value',
|
||||
'Purchase Rate',
|
||||
'Interest Rate',
|
||||
'Purchase Date',
|
||||
'Maturity Date',
|
||||
'Accrued Interest',
|
||||
'Maturity Value',
|
||||
'Status'
|
||||
];
|
||||
private readonly holdingsExportWidths = [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
12,
|
||||
16,
|
||||
14,
|
||||
12,
|
||||
14,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
12
|
||||
];
|
||||
private holdingsExportRow(h: InvestmentHolding): (string | number | null)[] {
|
||||
return [
|
||||
h.certNo,
|
||||
h.scriptName || h.party,
|
||||
h.subType,
|
||||
h.units,
|
||||
h.faceValue,
|
||||
h.purchaseRate,
|
||||
h.interestRate,
|
||||
h.purchaseDate,
|
||||
h.maturityDate,
|
||||
this.accrued(h),
|
||||
this.maturity(h),
|
||||
h.status
|
||||
];
|
||||
}
|
||||
|
||||
private readonly txnExportHeaders = [
|
||||
'Date',
|
||||
'Voucher No',
|
||||
'Cert No',
|
||||
'Type',
|
||||
'Particulars',
|
||||
'Amount'
|
||||
];
|
||||
private readonly txnExportWidths = [
|
||||
14,
|
||||
22,
|
||||
14,
|
||||
14,
|
||||
42,
|
||||
16
|
||||
];
|
||||
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
|
||||
return [
|
||||
t.txnDate,
|
||||
t.voucherNo,
|
||||
t.certNo,
|
||||
t.txnType,
|
||||
t.particulars,
|
||||
t.amount
|
||||
];
|
||||
}
|
||||
|
||||
private exportSheets(): ExportSheetSpec[] {
|
||||
const subtitle = `As on ${this.asOnDate}`;
|
||||
const one = (
|
||||
sheetName: string,
|
||||
title: string,
|
||||
headers: string[],
|
||||
widths: number[],
|
||||
rows: (string | number | null)[][]
|
||||
): ExportSheetSpec[] => [
|
||||
{ sheetName, title, subtitle, headers, widths, rows }];
|
||||
|
||||
switch (this.activeReport) {
|
||||
case 'govtSecBalance':
|
||||
return one(
|
||||
'Govt Sec Balance',
|
||||
'Govt Security Balance Report',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.govtSecHoldings.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'shareBalance':
|
||||
return one(
|
||||
'Share Balance',
|
||||
'Share Balance Report',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.shareHoldings.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'fdrBalance':
|
||||
return one(
|
||||
'FDR Balance',
|
||||
'FDR Balance Report',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.fdHoldings.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'bankInvList':
|
||||
return one(
|
||||
'Bank Investment List',
|
||||
'Bank Investment List',
|
||||
[
|
||||
'Party',
|
||||
'Holdings',
|
||||
'Total Face Value'
|
||||
],
|
||||
[
|
||||
30,
|
||||
14,
|
||||
18
|
||||
],
|
||||
this.bankInvestmentList.map((r) => [
|
||||
r.party,
|
||||
r.count,
|
||||
r.totalFaceValue
|
||||
])
|
||||
);
|
||||
case 'fdLedger':
|
||||
return one(
|
||||
'FD Ledger',
|
||||
'FD Ledger',
|
||||
this.txnExportHeaders,
|
||||
this.txnExportWidths,
|
||||
this.fdTxns.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'bondLedger':
|
||||
return one(
|
||||
'Govt-Bond Ledger',
|
||||
'Govt/Bond Ledger',
|
||||
this.txnExportHeaders,
|
||||
this.txnExportWidths,
|
||||
this.bondTxns.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'shareLedger':
|
||||
return one(
|
||||
'Share Ledger',
|
||||
'Share Ledger',
|
||||
this.txnExportHeaders,
|
||||
this.txnExportWidths,
|
||||
this.shareTxns.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'amortizeLedger':
|
||||
return one(
|
||||
'Amortize Ledger',
|
||||
'Amortize Ledger',
|
||||
this.txnExportHeaders,
|
||||
this.txnExportWidths,
|
||||
this.amortizeTxns.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'dealSlip':
|
||||
return one(
|
||||
'Deal Slip',
|
||||
'Deal Slip Register',
|
||||
this.txnExportHeaders,
|
||||
this.txnExportWidths,
|
||||
this.dealSlips.map((t) => this.txnExportRow(t))
|
||||
);
|
||||
case 'intDueRegister':
|
||||
return one(
|
||||
'Interest Due Register',
|
||||
'Interest Due Register (Non-Cumulative Holdings)',
|
||||
[
|
||||
...this.holdingsExportHeaders,
|
||||
'Due Amount'
|
||||
],
|
||||
[
|
||||
...this.holdingsExportWidths,
|
||||
16
|
||||
],
|
||||
this.interestDueRegister.map((h) => [
|
||||
...this.holdingsExportRow(h),
|
||||
h.dueAmount
|
||||
])
|
||||
);
|
||||
case 'slrRegister':
|
||||
return one(
|
||||
'SLR Bond Register',
|
||||
'SLR Bond Register',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.slrBondRegister.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'pendingReceived':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Received Interest',
|
||||
title: 'Received Interest / Dividend',
|
||||
subtitle,
|
||||
headers: this.txnExportHeaders,
|
||||
widths: this.txnExportWidths,
|
||||
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t))
|
||||
},
|
||||
{
|
||||
sheetName: 'Pending Interest',
|
||||
title: 'Pending Interest (Not Yet Posted)',
|
||||
subtitle,
|
||||
headers: this.holdingsExportHeaders,
|
||||
widths: this.holdingsExportWidths,
|
||||
rows: this.pendingInterestHoldings.map((h) => this.holdingsExportRow(h))
|
||||
}
|
||||
];
|
||||
case 'maturedFd':
|
||||
return one(
|
||||
'Matured FD',
|
||||
'List of Matured FD',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.maturedFdList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'maturingList':
|
||||
return one(
|
||||
'Maturity List',
|
||||
'Govt Security / FD Maturity List',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.maturingList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'maturityPattern':
|
||||
return one(
|
||||
'Maturity Pattern',
|
||||
'Maturity Pattern Register',
|
||||
[
|
||||
'Bucket',
|
||||
'Holdings',
|
||||
'Face Value'
|
||||
],
|
||||
[
|
||||
22,
|
||||
14,
|
||||
18
|
||||
],
|
||||
this.maturityBuckets.map((b) => [
|
||||
b.label,
|
||||
b.count,
|
||||
b.faceValue
|
||||
])
|
||||
);
|
||||
case 'govtSecList':
|
||||
return one(
|
||||
'Govt Security List',
|
||||
'Govt Security List',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.govtSecList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'fdSecList':
|
||||
return one(
|
||||
'FD Security List',
|
||||
'FD Security List',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.fdSecList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'shareSecList':
|
||||
return one(
|
||||
'Share Security List',
|
||||
'Share Security List',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.shareSecList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'mfList':
|
||||
return one(
|
||||
'Mutual Fund List',
|
||||
'Mutual Fund List',
|
||||
this.holdingsExportHeaders,
|
||||
this.holdingsExportWidths,
|
||||
this.mutualFundList.map((h) => this.holdingsExportRow(h))
|
||||
);
|
||||
case 'shiftingReport':
|
||||
return one(
|
||||
'Shifting Report',
|
||||
'Shifting Report — SLR Market Deviation ≥ 1%',
|
||||
[
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Purchase Rate',
|
||||
'Market Price',
|
||||
'Deviation %'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
14
|
||||
],
|
||||
this.shiftingCandidates.map((x) => [
|
||||
x.certNo,
|
||||
x.scriptName,
|
||||
x.purchaseRate,
|
||||
x.marketPrice,
|
||||
x.deviationPct
|
||||
])
|
||||
);
|
||||
case 'portfolio':
|
||||
return one(
|
||||
'Portfolio',
|
||||
'Investment Portfolio — Consolidated Summary',
|
||||
[
|
||||
'Instrument Type',
|
||||
'Holdings',
|
||||
'Face Value',
|
||||
'Accrued Interest',
|
||||
'Maturity Value'
|
||||
],
|
||||
[
|
||||
24,
|
||||
12,
|
||||
18,
|
||||
18,
|
||||
18
|
||||
],
|
||||
this.portfolioSummary.map((p) => [
|
||||
p.label,
|
||||
p.count,
|
||||
p.faceValue,
|
||||
p.accrued,
|
||||
p.maturityValue
|
||||
])
|
||||
);
|
||||
default:
|
||||
return one('Report', 'Investment Report', this.holdingsExportHeaders, this.holdingsExportWidths, []);
|
||||
}
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(this.exportSheets(), `Investment-${this.activeReport}-${this.asOnDate}`);
|
||||
this.toast('Report exported.');
|
||||
} catch (err) {
|
||||
console.error('Investment report 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;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
|
||||
</p>
|
||||
</div>
|
||||
@if (activeTab === 'holdings') {
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
|
||||
|
||||
@@ -50,6 +51,7 @@ export class ShareInvestmentComponent {
|
||||
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
|
||||
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -252,6 +254,59 @@ export class ShareInvestmentComponent {
|
||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel (Holdings register) --------------------
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Share Holdings',
|
||||
title: 'Share Holdings Register',
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Party',
|
||||
'Units',
|
||||
'Purchase Rate',
|
||||
'Face Value',
|
||||
'Purchase Date',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
24,
|
||||
22,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
12
|
||||
],
|
||||
rows: this.holdings.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
h.party,
|
||||
h.units,
|
||||
h.purchaseRate,
|
||||
h.faceValue,
|
||||
h.purchaseDate,
|
||||
h.status
|
||||
])
|
||||
}
|
||||
],
|
||||
`Share-Holdings-${this.asOnDate}`
|
||||
);
|
||||
this.toast('Share Holdings exported.');
|
||||
} catch (err) {
|
||||
console.error('Share Holdings 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;
|
||||
|
||||
@@ -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