diff --git a/src/app/investment/bond-investment/bond-investment.component.html b/src/app/investment/bond-investment/bond-investment.component.html index 10f310bd8..d2561a1d6 100644 --- a/src/app/investment/bond-investment/bond-investment.component.html +++ b/src/app/investment/bond-investment/bond-investment.component.html @@ -25,6 +25,11 @@ } + @if (activeTab === 'holdings') { + + } diff --git a/src/app/investment/bond-investment/bond-investment.component.ts b/src/app/investment/bond-investment/bond-investment.component.ts index de971e980..9b65964c2 100644 --- a/src/app/investment/bond-investment/bond-investment.component.ts +++ b/src/app/investment/bond-investment/bond-investment.component.ts @@ -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 | undefined; @@ -1092,6 +1094,68 @@ export class BondInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + 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; diff --git a/src/app/investment/fd-investment/fd-investment.component.html b/src/app/investment/fd-investment/fd-investment.component.html index bac98204f..fffa89741 100644 --- a/src/app/investment/fd-investment/fd-investment.component.html +++ b/src/app/investment/fd-investment/fd-investment.component.html @@ -16,6 +16,13 @@ Part-Payment entries.

+ @if (activeTab === 'holdings') { +
+ +
+ }
diff --git a/src/app/investment/fd-investment/fd-investment.component.ts b/src/app/investment/fd-investment/fd-investment.component.ts index 739adf032..0d3c10226 100644 --- a/src/app/investment/fd-investment/fd-investment.component.ts +++ b/src/app/investment/fd-investment/fd-investment.component.ts @@ -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 | undefined; @@ -450,6 +452,68 @@ export class FdInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + 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; diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.html b/src/app/investment/investment-dashboard/investment-dashboard.component.html index 01f9bb2ec..36daffc84 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.html +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.html @@ -16,6 +16,11 @@ {{ asOnDate }}.

+
+ +
@@ -196,3 +201,5 @@
+ +
{{ toastMsg }}
diff --git a/src/app/investment/investment-dashboard/investment-dashboard.component.ts b/src/app/investment/investment-dashboard/investment-dashboard.component.ts index 8640f4cff..5c6166a82 100644 --- a/src/app/investment/investment-dashboard/investment-dashboard.component.ts +++ b/src/app/investment/investment-dashboard/investment-dashboard.component.ts @@ -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 | 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 { + 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); + } } diff --git a/src/app/investment/investment-export.ts b/src/app/investment/investment-export.ts new file mode 100644 index 000000000..b04f1c2ec --- /dev/null +++ b/src/app/investment/investment-export.ts @@ -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 { + 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); +} diff --git a/src/app/investment/investment-interest/investment-interest.component.html b/src/app/investment/investment-interest/investment-interest.component.html index 3423802ea..71a0396ff 100644 --- a/src/app/investment/investment-interest/investment-interest.component.html +++ b/src/app/investment/investment-interest/investment-interest.component.html @@ -16,6 +16,11 @@ Government Bond Interest, FD Interest and FD registers.

+
+ +
diff --git a/src/app/investment/investment-interest/investment-interest.component.ts b/src/app/investment/investment-interest/investment-interest.component.ts index 751d5d2ef..4a90fcff5 100644 --- a/src/app/investment/investment-interest/investment-interest.component.ts +++ b/src/app/investment/investment-interest/investment-interest.component.ts @@ -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 = {}; + generating = false; toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | 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 { + 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; diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index d44cbf788..6c62c4d0d 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -16,6 +16,11 @@ Market Detail masters.

+
+ +
diff --git a/src/app/investment/investment-masters/investment-masters.component.ts b/src/app/investment/investment-masters/investment-masters.component.ts index de324cc4d..ee99159a5 100644 --- a/src/app/investment/investment-masters/investment-masters.component.ts +++ b/src/app/investment/investment-masters/investment-masters.component.ts @@ -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 = { + 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 | 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 { + 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; diff --git a/src/app/investment/investment-reports/investment-reports.component.html b/src/app/investment/investment-reports/investment-reports.component.html index cae27e4a5..f8c91431b 100644 --- a/src/app/investment/investment-reports/investment-reports.component.html +++ b/src/app/investment/investment-reports/investment-reports.component.html @@ -16,6 +16,11 @@ same holdings/transactions used by the Entry screens.

+
+ +
@@ -692,3 +697,5 @@
} + +
{{ toastMsg }}
diff --git a/src/app/investment/investment-reports/investment-reports.component.ts b/src/app/investment/investment-reports/investment-reports.component.ts index 551a5423a..51eddea92 100644 --- a/src/app/investment/investment-reports/investment-reports.component.ts +++ b/src/app/investment/investment-reports/investment-reports.component.ts @@ -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 | 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 { + 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); + } } diff --git a/src/app/investment/share-investment/share-investment.component.html b/src/app/investment/share-investment/share-investment.component.html index 7312ec74c..7587fd2ab 100644 --- a/src/app/investment/share-investment/share-investment.component.html +++ b/src/app/investment/share-investment/share-investment.component.html @@ -15,6 +15,13 @@ Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.

+ @if (activeTab === 'holdings') { +
+ +
+ }
diff --git a/src/app/investment/share-investment/share-investment.component.ts b/src/app/investment/share-investment/share-investment.component.ts index f5835ee0d..1ce49d99c 100644 --- a/src/app/investment/share-investment/share-investment.component.ts +++ b/src/app/investment/share-investment/share-investment.component.ts @@ -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 | undefined; @@ -252,6 +254,59 @@ export class ShareInvestmentComponent { return `inv-badge inv-badge--${h.status.toLowerCase()}`; } + // -------------------- Export to Excel (Holdings register) -------------------- + async exportExcel(): Promise { + 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; diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html index f6090a664..6b5251986 100644 --- a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html @@ -51,6 +51,11 @@ + @if (!isRestricted) { + + }
@@ -225,4 +230,42 @@ } +@if (showExportModal) { +
+
+
+ Export to Excel — Sign-off (Optional) + +
+

+ Fill in Prepared / Checked / Approved details if this copy needs them for the record — or leave them blank and + download straight away. +

+
+
+ Prepared By + + +
+
+ Checked By + + +
+
+ Approved By + + +
+
+
+ + +
+
+
+} +
{{ toastMsg }}
diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts index 683398761..dc13bba98 100644 --- a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts @@ -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 = {}; + generating = false; + showExportModal = false; + exportSignOff: ReconExportSignOff = blankExportSignOff(); + toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | 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 { + 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; diff --git a/src/app/reconciliation/reconciliation-shared.scss b/src/app/reconciliation/reconciliation-shared.scss index edc6a1fae..1d6c4b26e 100644 --- a/src/app/reconciliation/reconciliation-shared.scss +++ b/src/app/reconciliation/reconciliation-shared.scss @@ -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;