/** * 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/. */ import * as ExcelJS from 'exceljs'; import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model'; /** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */ export const XL = { banner: 'FF002F5F', bannerText: 'FFFFFFFF', headerGrey: 'FFC0C0C0', derivedBlue: 'FF9CC3F5', auditYellow: 'FFFFFFCC', keyCell: 'FFD9D9D9', border: 'FF000000' }; const AMOUNT_FMT = '#,##0.00'; const COUNT_FMT = '#,##0'; export function solidFill(argb: string): ExcelJS.Fill { return { type: 'pattern', pattern: 'solid', fgColor: { argb } }; } /** * The diagonal hatching RBI's templates put on a cell that is waiting to be filled in by hand. * * It reads differently from an empty cell, and the difference matters on a statutory form: an empty * cell says nothing was reported, hatching says someone still has to report it. The templates use * it on the signatory block and on the particulars a bank writes in itself. */ export function hatchFill(): ExcelJS.Fill { return { type: 'pattern', pattern: 'lightUp', fgColor: { argb: 'FF808080' }, bgColor: { argb: 'FFFFFFFF' } }; } /** Dark-blue full-width banner that heads every sheet in the RBI templates. */ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void { const row = sheet.addRow([title]); sheet.mergeCells(`A${row.number}:${lastCol}${row.number}`); row.height = 26; const cell = row.getCell(1); cell.fill = solidFill(XL.banner); cell.font = { bold: true, size: 13, color: { argb: XL.bannerText } }; cell.alignment = { vertical: 'middle', horizontal: 'center' }; sheet.addRow([]); } export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void { for (let r = from; r <= to; r++) { for (let c = 1; c <= lastColIndex; c++) { sheet.getRow(r).getCell(c).border = { top: { style: 'thin', color: { argb: XL.border } }, left: { style: 'thin', color: { argb: XL.border } }, bottom: { style: 'thin', color: { argb: XL.border } }, right: { style: 'thin', color: { argb: XL.border } } }; } } } export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void { const row = sheet.addRow([ label, unitLabel ?? '' ]); row.font = { bold: true }; row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey))); row.getCell(2).alignment = { horizontal: 'right' }; } /** Renders a labelled block; indents by level and shades derived totals. */ export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void { for (const line of lines) { const row = sheet.addRow([ line.label, line.value ]); const labelCell = row.getCell(1); const valueCell = row.getCell(2); labelCell.alignment = { wrapText: true, vertical: 'middle', indent: line.level }; if (line.level === 0) { labelCell.font = { bold: true }; } valueCell.numFmt = line.count ? COUNT_FMT : AMOUNT_FMT; valueCell.alignment = { horizontal: 'right' }; if (line.total) { valueCell.fill = solidFill(XL.derivedBlue); valueCell.font = { bold: true }; } } } /** * Writes a multi-column statement: title band, column header row, then the matrix * rows. Returns the row number after the block so callers can keep stacking. */ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): void { const colCount = table.columns.length + 1; const start = sheet.rowCount + 1; const titleRow = sheet.addRow([ table.title, ...(table.unit ? [table.unit] : []) ]); if (!table.unit) { sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount); } titleRow.font = { bold: true }; for (let c = 1; c <= colCount; c++) { titleRow.getCell(c).fill = solidFill(XL.headerGrey); } if (table.subtitle) { const sub = sheet.addRow([table.subtitle]); sheet.mergeCells(sub.number, 1, sub.number, colCount); sub.font = { bold: true, size: 10 }; sub.alignment = { wrapText: true }; sub.getCell(1).fill = solidFill(XL.headerGrey); } const head = sheet.addRow([ 'Particulars', ...table.columns ]); head.font = { bold: true }; head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); for (const row of table.rows) { const excelRow = sheet.addRow([ row.label, ...row.values ]); const labelCell = excelRow.getCell(1); labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level }; if (row.level === 0 || row.header) { labelCell.font = { bold: true }; } if (row.header) { labelCell.fill = solidFill(XL.headerGrey); continue; } for (let c = 2; c <= colCount; c++) { const cell = excelRow.getCell(c); cell.numFmt = row.percent ? '0.00"%"' : AMOUNT_FMT; cell.alignment = { horizontal: 'right' }; if (row.total) { cell.fill = solidFill(XL.derivedBlue); cell.font = { bold: true }; } } } boxAllCells(sheet, start, sheet.rowCount, colCount); for (const note of table.notes ?? []) { const noteRow = sheet.addRow([note]); sheet.mergeCells(noteRow.number, 1, noteRow.number, colCount); noteRow.font = { bold: true, size: 9 }; noteRow.alignment = { wrapText: true }; noteRow.getCell(1).fill = solidFill(XL.headerGrey); } sheet.addRow([]); } /** The General Information sheet is identical in shape across every OSS return. */ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralInfoRow[]): void { const sheet = wb.addWorksheet('General Information'); sheet.columns = [ { width: 26 }, { width: 58 }]; addBanner(sheet, 'General Information', 'B'); const first = sheet.rowCount + 1; for (const item of rows) { const row = sheet.addRow([ item.label, item.value ]); row.getCell(1).fill = solidFill(XL.keyCell); // The workbook tints the audit flag yellow and leaves unfilled cells grey. const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey; row.getCell(2).fill = solidFill(fill); } boxAllCells(sheet, first, sheet.rowCount, 2); } /** The Signatory sheet is likewise identical across returns. */ export function buildSignatorySheet( wb: ExcelJS.Workbook, signatories: ReturnSignatory[], sheetName = 'Signatory' ): void { const sheet = wb.addWorksheet(sheetName); sheet.columns = [ { width: 44 }, { width: 24 }]; addBanner(sheet, sheetName, 'B'); for (const person of signatories) { const start = sheet.rowCount + 1; const head = sheet.addRow([ person.heading, '' ]); head.font = { bold: true }; head.alignment = { horizontal: 'center' }; head.getCell(1).fill = solidFill(XL.keyCell); for (const f of SIGNATORY_FIELDS) { const row = sheet.addRow([ person[f.key], f.caption ]); row.getCell(2).font = { bold: true }; row.getCell(2).alignment = { horizontal: 'center' }; row.getCell(2).fill = solidFill(XL.keyCell); } boxAllCells(sheet, start, sheet.rowCount, 2); sheet.addRow([]); } } /** Serialises the workbook and triggers a browser download. */ export async function downloadWorkbook(wb: ExcelJS.Workbook, filename: string): Promise { 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; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } export function newWorkbook(): ExcelJS.Workbook { const wb = new ExcelJS.Workbook(); wb.creator = 'CBSNexora'; wb.created = new Date(); return wb; }