fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding
- Add Balance Sheet report and disabled-features accounting config - Point menu-access tenant header and env.js/environment.ts tenant identifier at env config instead of hardcoded 'default'/'lincpay' literals - Surface the real backend error (not a generic "no reference id") when an OTP send fails in contact-otp-block - Fix branch-day "₹null" display bug (null vs undefined) and add a guard blocking a branch's first-ever day open before cash denomination migration - Dynamically size the denomination count input and block re-opening an already-open business day from Migrate Cash Denomination - Lock the denomination-managed cash GL row in Migrate Opening Balances (read-only, with a link back to Migrate Cash Denomination) - Remove duplicate Cancel/Submit action bars on the Share Product and Savings Account creation preview steps; align Previous into the same row - Hide "Apply Collection" on a share account once its advance collection is already settled (further purchases go through Apply Additional Shares) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities
|
||||
* on the left, assets on the right, each top-level group a named block with its accounts listed
|
||||
* under it and a subtotal in the TOTAL column.
|
||||
*
|
||||
* Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app
|
||||
* shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks,
|
||||
* which is what made the printed sheet unreadable. Here each column is paginated on its own, the
|
||||
* way the printed report does it — the left side can run onto page 2 while the right side is still
|
||||
* on page 1 — and every page repeats the heading.
|
||||
*/
|
||||
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
export interface BalanceSheetPdfNode {
|
||||
name: string;
|
||||
glCode: string;
|
||||
usage: 'HEADER' | 'DETAIL';
|
||||
balance: number;
|
||||
children: BalanceSheetPdfNode[];
|
||||
}
|
||||
|
||||
export interface BalanceSheetPdfSide {
|
||||
label: string;
|
||||
total: number;
|
||||
groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[];
|
||||
}
|
||||
|
||||
export interface BalanceSheetPdfInput {
|
||||
bankName: string;
|
||||
officeLabel: string;
|
||||
/** yyyy-MM-dd */
|
||||
asOnDate: string;
|
||||
creditSide: BalanceSheetPdfSide;
|
||||
debitSide: BalanceSheetPdfSide;
|
||||
}
|
||||
|
||||
// ── Page geometry, in mm on A4 portrait ─────────────────────────────────────
|
||||
const PAGE_W = 210;
|
||||
const PAGE_H = 297;
|
||||
const MARGIN = 8;
|
||||
const COL_W = (PAGE_W - MARGIN * 2) / 2;
|
||||
const COL_X = [
|
||||
MARGIN,
|
||||
MARGIN + COL_W
|
||||
];
|
||||
/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */
|
||||
const BALANCE_R = COL_W - 26;
|
||||
const TOTAL_R = COL_W - 2;
|
||||
/** Name text wraps within this width so it never runs under the BALANCE column. */
|
||||
const NAME_W = COL_W - 30;
|
||||
|
||||
const BODY_TOP = 36;
|
||||
/** Leaves room under the body for the sign-off row and the print-date footer. */
|
||||
const BODY_BOTTOM = PAGE_H - 21;
|
||||
const LINE_H = 3.1;
|
||||
|
||||
const INK: [number, number, number] = [
|
||||
20,
|
||||
20,
|
||||
20
|
||||
];
|
||||
const MUTED: [number, number, number] = [
|
||||
90,
|
||||
90,
|
||||
90
|
||||
];
|
||||
|
||||
type Line =
|
||||
| { kind: 'block'; text: string }
|
||||
| { kind: 'subhead'; text: string; depth: number }
|
||||
| { kind: 'detail'; lines: string[]; amount: string; depth: number }
|
||||
| { kind: 'subsubtotal'; amount: string }
|
||||
| { kind: 'subtotal'; amount: string }
|
||||
| { kind: 'standalone'; lines: string[]; amount: string }
|
||||
| { kind: 'grand'; amount: string }
|
||||
| { kind: 'gap' };
|
||||
|
||||
function money(value: number): string {
|
||||
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
|
||||
}
|
||||
|
||||
function ddmmyyyy(iso: string): string {
|
||||
if (!iso) {
|
||||
return '';
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = iso.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function heightOf(line: Line): number {
|
||||
switch (line.kind) {
|
||||
case 'block':
|
||||
// Heading plus the short dashed rule beneath it.
|
||||
return LINE_H + 1.8;
|
||||
case 'detail':
|
||||
case 'standalone':
|
||||
return LINE_H * line.lines.length;
|
||||
case 'gap':
|
||||
return 2.4;
|
||||
case 'grand':
|
||||
// Rule above, the figure, and the double rule beneath it.
|
||||
return LINE_H + 6;
|
||||
default:
|
||||
return LINE_H;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens one side into the exact sequence of printed lines. Done up front so pagination is a
|
||||
* matter of measuring heights, with no recursion happening mid-page.
|
||||
*/
|
||||
function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] {
|
||||
const out: Line[] = [];
|
||||
// splitTextToSize measures with whatever font is currently set, so it has to match the font the
|
||||
// detail lines are actually drawn in — otherwise names wrap at the wrong width.
|
||||
doc.setFont('helvetica', 'normal').setFontSize(6.8);
|
||||
|
||||
const pushNode = (node: BalanceSheetPdfNode, depth: number): void => {
|
||||
if (node.usage === 'DETAIL') {
|
||||
const indent = depth * 2;
|
||||
const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent);
|
||||
out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth });
|
||||
return;
|
||||
}
|
||||
out.push({ kind: 'subhead', text: node.name, depth });
|
||||
node.children.forEach((child) => pushNode(child, depth + 1));
|
||||
out.push({ kind: 'subsubtotal', amount: money(node.balance) });
|
||||
};
|
||||
|
||||
for (const group of side.groups) {
|
||||
for (const root of group.nodes) {
|
||||
if (root.usage === 'DETAIL') {
|
||||
// A Detail account sitting under no group at all: a group of one, printed on a single line
|
||||
// carrying both its balance and its total, rather than a heading and a figure on separate
|
||||
// rows with the BALANCE column left empty.
|
||||
out.push({
|
||||
kind: 'standalone',
|
||||
lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W),
|
||||
amount: money(root.balance)
|
||||
});
|
||||
out.push({ kind: 'gap' });
|
||||
continue;
|
||||
}
|
||||
out.push({ kind: 'block', text: root.name });
|
||||
root.children.forEach((child) => pushNode(child, 0));
|
||||
out.push({ kind: 'subtotal', amount: money(root.balance) });
|
||||
out.push({ kind: 'gap' });
|
||||
}
|
||||
}
|
||||
// Carried as a line of its own so pagination reserves room for it, rather than squeezing it
|
||||
// against the bottom margin under the last block.
|
||||
out.push({ kind: 'grand', amount: money(side.total) });
|
||||
return out;
|
||||
}
|
||||
|
||||
function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void {
|
||||
doc.setLineDashPattern(
|
||||
[
|
||||
0.4,
|
||||
0.6
|
||||
],
|
||||
0
|
||||
);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(x1, y, x2, y);
|
||||
doc.setLineDashPattern([], 0);
|
||||
}
|
||||
|
||||
function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void {
|
||||
doc.setTextColor(...INK);
|
||||
|
||||
doc.setFont('helvetica', 'bold').setFontSize(9.5);
|
||||
doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' });
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, {
|
||||
align: 'center'
|
||||
});
|
||||
doc.setFont('helvetica', 'normal').setFontSize(7);
|
||||
doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' });
|
||||
doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' });
|
||||
|
||||
dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN);
|
||||
|
||||
// Side titles and column headings, drawn per column.
|
||||
const titles = [
|
||||
'LIABILITIES ( CREDIT )',
|
||||
'ASSETS ( DEBIT )'
|
||||
];
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const x = COL_X[c];
|
||||
doc.setFont('helvetica', 'bold').setFontSize(7.5);
|
||||
doc.text(titles[c], x + 1, 27.5);
|
||||
doc.setFontSize(6.5);
|
||||
doc.text('ACCOUNT NAME', x + 1, 32);
|
||||
doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' });
|
||||
doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' });
|
||||
dottedLine(doc, x + 1, 33.4, x + COL_W - 2);
|
||||
}
|
||||
|
||||
// The divider between the two sides.
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setDrawColor(150, 150, 150);
|
||||
doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2);
|
||||
doc.setDrawColor(0, 0, 0);
|
||||
|
||||
// Footer.
|
||||
dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN);
|
||||
doc
|
||||
.setFont('helvetica', 'normal')
|
||||
.setFontSize(6.5)
|
||||
.setTextColor(...MUTED);
|
||||
const now = new Date();
|
||||
const stamp =
|
||||
`${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` +
|
||||
`${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`;
|
||||
doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10);
|
||||
doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' });
|
||||
doc.setTextColor(...INK);
|
||||
}
|
||||
|
||||
/** Draws one line into a column at y, and returns the y the next line starts at. */
|
||||
function drawLine(doc: jsPDF, line: Line, x: number, y: number): number {
|
||||
switch (line.kind) {
|
||||
case 'block': {
|
||||
doc.setFont('helvetica', 'bold').setFontSize(7);
|
||||
doc.text(line.text.toUpperCase(), x + 1, y);
|
||||
dottedLine(doc, x + 1, y + 1.1, x + 34);
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'subhead': {
|
||||
doc.setFont('helvetica', 'bold').setFontSize(6.8);
|
||||
doc.text(line.text, x + 1 + line.depth * 2, y);
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'detail': {
|
||||
doc.setFont('helvetica', 'normal').setFontSize(6.8);
|
||||
const indent = x + 2 + line.depth * 2;
|
||||
line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H));
|
||||
// The amount sits on the first line of a wrapped name, as the printed report has it.
|
||||
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'subsubtotal': {
|
||||
doc.setFont('helvetica', 'bold').setFontSize(6.8);
|
||||
dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R);
|
||||
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'subtotal': {
|
||||
doc.setFont('helvetica', 'bold').setFontSize(7);
|
||||
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'standalone': {
|
||||
doc.setFont('helvetica', 'bold').setFontSize(7);
|
||||
line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H));
|
||||
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
|
||||
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
|
||||
return y + heightOf(line);
|
||||
}
|
||||
case 'grand': {
|
||||
const top = y + 1.5;
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(x + 1, top, x + COL_W - 2, top);
|
||||
doc.setFont('helvetica', 'bold').setFontSize(7.5);
|
||||
doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' });
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(x + 1, top + 5, x + COL_W - 2, top + 5);
|
||||
doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7);
|
||||
return y + heightOf(line);
|
||||
}
|
||||
default:
|
||||
return y + heightOf(line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */
|
||||
function paginate(lines: Line[]): Line[][] {
|
||||
const pages: Line[][] = [];
|
||||
let current: Line[] = [];
|
||||
let y = BODY_TOP;
|
||||
|
||||
for (const line of lines) {
|
||||
const h = heightOf(line);
|
||||
if (y + h > BODY_BOTTOM) {
|
||||
pages.push(current);
|
||||
current = [];
|
||||
y = BODY_TOP;
|
||||
}
|
||||
current.push(line);
|
||||
y += h;
|
||||
}
|
||||
// Keep the trailing chunk only if it has something on it, but always return at least one page so
|
||||
// a side with no balances still gets its heading and a zero total.
|
||||
if (current.length > 0 || pages.length === 0) {
|
||||
pages.push(current);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF {
|
||||
const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true });
|
||||
|
||||
const creditPages = paginate(flatten(doc, input.creditSide));
|
||||
const debitPages = paginate(flatten(doc, input.debitSide));
|
||||
// Both sides print side by side, so the sheet runs as long as the longer of the two, plus a
|
||||
// final page for the grand totals when they would not fit under the last block.
|
||||
const pageCount = Math.max(creditPages.length, debitPages.length);
|
||||
|
||||
for (let p = 0; p < pageCount; p++) {
|
||||
if (p > 0) {
|
||||
doc.addPage();
|
||||
}
|
||||
drawPageFrame(doc, input, p + 1, pageCount);
|
||||
|
||||
const sides = [
|
||||
creditPages[p] ?? [],
|
||||
debitPages[p] ?? []
|
||||
];
|
||||
|
||||
for (let c = 0; c < 2; c++) {
|
||||
const x = COL_X[c];
|
||||
let y = BODY_TOP;
|
||||
for (const line of sides[c]) {
|
||||
y = drawLine(doc, line, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Sign-off row, on the closing page only.
|
||||
if (p === pageCount - 1) {
|
||||
doc
|
||||
.setFont('helvetica', 'normal')
|
||||
.setFontSize(6.5)
|
||||
.setTextColor(...MUTED);
|
||||
const roles = [
|
||||
'CLERK',
|
||||
'ACCOUNTANT / OFFICER',
|
||||
'ASST. GEN MANAGER',
|
||||
'GENERAL MANAGER',
|
||||
'CHAIRMAN'
|
||||
];
|
||||
const step = (PAGE_W - MARGIN * 2) / roles.length;
|
||||
roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' }));
|
||||
doc.setTextColor(...INK);
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
Reference in New Issue
Block a user