251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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;
|
|
}
|