Files
cbsnexora/src/app/rbi-circulars/oss-workbook.ts
T

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;
}