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;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="bs-page">
|
||||
<!-- Controls — not part of the printed sheet. -->
|
||||
<div class="bs-controls">
|
||||
<label class="bs-field">
|
||||
<span>As on date</span>
|
||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
||||
</label>
|
||||
<label class="bs-field">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="officeId" (ngModelChange)="load()">
|
||||
<option [ngValue]="null">All branches</option>
|
||||
@for (office of offices; track office.id) {
|
||||
<option [ngValue]="office.id">{{ office.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="bs-check">
|
||||
<input type="checkbox" [(ngModel)]="hideZeroBalances" (ngModelChange)="applyZeroFilter()" />
|
||||
<span>Hide nil balances</span>
|
||||
</label>
|
||||
<span class="bs-controls__spacer"></span>
|
||||
@if (!loading && debitSide && creditSide) {
|
||||
<span class="bs-tally" [class.bs-tally--off]="!tallied">
|
||||
{{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }}
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="bs-btn" (click)="load()" [disabled]="loading">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</button>
|
||||
<button type="button" class="bs-btn bs-btn--primary" (click)="downloadPdf()" [disabled]="loading || !debitSide">
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="bs-alert">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="bs-loading">Loading balance sheet…</div>
|
||||
}
|
||||
|
||||
@if (!loading && debitSide && creditSide) {
|
||||
<!-- The sheet itself — this block is what prints. -->
|
||||
<div class="bs-sheet">
|
||||
<header class="bs-sheet__head">
|
||||
<h2>CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)</h2>
|
||||
<p>AS ON : {{ displayDate(asOnDateLoaded) }}</p>
|
||||
<p class="bs-sheet__branches">FOR BRANCHES : {{ officeLabel }}</p>
|
||||
</header>
|
||||
|
||||
<div class="bs-sheet__body">
|
||||
<!-- Liabilities (credit) on the left, assets (debit) on the right — as the printed sheet reads. -->
|
||||
<section class="bs-col">
|
||||
<div class="bs-col__title">LIABILITIES ( CREDIT )</div>
|
||||
<div class="bs-row bs-row--colhead">
|
||||
<span>ACCOUNT NAME</span>
|
||||
<span>BALANCE</span>
|
||||
<span>TOTAL</span>
|
||||
</div>
|
||||
@for (group of creditSide.groups; track group.classification) {
|
||||
@for (root of group.nodes; track root.id) {
|
||||
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
|
||||
}
|
||||
}
|
||||
@if (creditSide.groups.length === 0) {
|
||||
<div class="bs-empty">No liability, equity or income balances as on this date.</div>
|
||||
}
|
||||
<div class="bs-row bs-row--grand">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span>{{ fmt(creditSide.total) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bs-col">
|
||||
<div class="bs-col__title">ASSETS ( DEBIT )</div>
|
||||
<div class="bs-row bs-row--colhead">
|
||||
<span>ACCOUNT NAME</span>
|
||||
<span>BALANCE</span>
|
||||
<span>TOTAL</span>
|
||||
</div>
|
||||
@for (group of debitSide.groups; track group.classification) {
|
||||
@for (root of group.nodes; track root.id) {
|
||||
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
|
||||
}
|
||||
}
|
||||
@if (debitSide.groups.length === 0) {
|
||||
<div class="bs-empty">No asset or expense balances as on this date.</div>
|
||||
}
|
||||
<div class="bs-row bs-row--grand">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span>{{ fmt(debitSide.total) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="bs-sheet__foot">
|
||||
<span>CLERK</span>
|
||||
<span>ACCOUNTANT / OFFICER</span>
|
||||
<span>ASST. GEN MANAGER</span>
|
||||
<span>GENERAL MANAGER</span>
|
||||
<span>CHAIRMAN</span>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
One top-level block: the Header account's name, a rule, its lines, then its subtotal in the TOTAL
|
||||
column.
|
||||
-->
|
||||
<ng-template #block let-root>
|
||||
@if (root.usage === 'DETAIL') {
|
||||
<!-- A Detail account with no Header of its own: a group of one, so it gets a single line
|
||||
carrying both its balance and its total rather than a heading repeating its own name. -->
|
||||
<div class="bs-block bs-block--single">
|
||||
<div class="bs-row bs-row--standalone">
|
||||
<span class="bs-name" [title]="root.glCode">{{ root.name }}</span>
|
||||
<span class="bs-amt">{{ fmt(root.balance) }}</span>
|
||||
<span class="bs-amt">{{ fmt(root.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bs-block">
|
||||
<div class="bs-block__name" [title]="root.glCode">{{ root.name }}</div>
|
||||
<div class="bs-block__rule"></div>
|
||||
@for (child of root.children; track child.id) {
|
||||
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: 0 }"></ng-container>
|
||||
}
|
||||
@if (root.children.length === 0) {
|
||||
<div class="bs-row bs-row--line">
|
||||
<span class="bs-name bs-name--muted">No accounts under this group</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
}
|
||||
<div class="bs-row bs-row--subtotal">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span>{{ fmt(root.balance) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!--
|
||||
Everything below a top-level block. A nested Header keeps its own name and its own subtotal, so a
|
||||
Header inside a Header still reads as its own sub-group rather than flattening into the parent.
|
||||
-->
|
||||
<ng-template #lines let-node let-depth="depth">
|
||||
@if (node.usage === 'DETAIL') {
|
||||
<div class="bs-row bs-row--line">
|
||||
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
|
||||
<span class="bs-amt">{{ fmt(node.balance) }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="bs-row bs-row--subhead">
|
||||
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
@for (child of node.children; track child.id) {
|
||||
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: depth + 1 }"></ng-container>
|
||||
}
|
||||
<div class="bs-row bs-row--subsubtotal">
|
||||
<span></span>
|
||||
<span class="bs-amt">{{ fmt(node.balance) }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$ink: #111827;
|
||||
$ink-soft: #374151;
|
||||
$ink-faint: #9ca3af;
|
||||
$rule: #cbd5e1;
|
||||
$border: #e2e8f0;
|
||||
$accent: #1d4ed8;
|
||||
$ok: #15803d;
|
||||
$ok-soft: #f0fdf4;
|
||||
$danger: #b91c1c;
|
||||
$danger-soft: #fef2f2;
|
||||
|
||||
// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet.
|
||||
$amt-col: 128px;
|
||||
|
||||
.bs-page {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f1f5f9;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// ── Controls (screen only) ────────────────────────────────────────────────
|
||||
.bs-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: $ink-soft;
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: $ink-soft;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: $ink-soft;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&--primary {
|
||||
background: $ink;
|
||||
border-color: $ink;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-tally {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: $ok-soft;
|
||||
color: $ok;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&--off {
|
||||
background: $danger-soft;
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: $danger-soft;
|
||||
color: $danger;
|
||||
border: 1px solid #fecaca;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bs-loading {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: $ink-faint;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
// ── The printed sheet ─────────────────────────────────────────────────────
|
||||
.bs-sheet {
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
padding: 22px 26px 18px;
|
||||
// The ledger reads as a printed report, so the whole sheet is set in a mono face with
|
||||
// figures on tabular numerals — columns stay aligned however long the names run.
|
||||
font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: $ink;
|
||||
|
||||
&__head {
|
||||
text-align: center;
|
||||
border-bottom: 1px dotted $rule;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: $ink-soft;
|
||||
}
|
||||
}
|
||||
|
||||
&__branches {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 26px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dotted $rule;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
color: $ink-soft;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-col {
|
||||
padding: 0 16px;
|
||||
min-width: 0;
|
||||
|
||||
&:first-child {
|
||||
border-right: 1px dotted $rule;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rows: one grid so name / BALANCE / TOTAL align across every block ──────
|
||||
.bs-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) $amt-col $amt-col;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
|
||||
> span:nth-child(2),
|
||||
> span:nth-child(3) {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--colhead {
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px dotted $rule;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
&--line {
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
// A nested Header inside a block: its own named sub-group.
|
||||
&--subhead {
|
||||
font-weight: 700;
|
||||
color: $ink;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
// Sub-group subtotal sits in the BALANCE column, so it never collides with the
|
||||
// block's own figure in TOTAL.
|
||||
&--subsubtotal {
|
||||
border-top: 1px dotted $rule;
|
||||
font-weight: 700;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
// Top-level block subtotal, in the TOTAL column.
|
||||
&--subtotal {
|
||||
font-weight: 700;
|
||||
color: $ink;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
// A Header-less Detail account: its own one-line group.
|
||||
&--standalone {
|
||||
font-weight: 700;
|
||||
color: $ink;
|
||||
|
||||
.bs-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
&--grand {
|
||||
margin-top: 14px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid $ink;
|
||||
border-bottom: 3px double $ink;
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-name {
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&--muted {
|
||||
color: $ink-faint;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-amt {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bs-block {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&--single {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// The short dashed rule the printed sheet puts under every group heading.
|
||||
&__rule {
|
||||
width: 130px;
|
||||
border-top: 1px dotted $ink-faint;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.bs-empty {
|
||||
padding: 14px 0;
|
||||
font-size: 11.5px;
|
||||
color: $ink-faint;
|
||||
}
|
||||
|
||||
// ── Narrow screens: stack the two sides instead of squeezing the columns ───
|
||||
@media (width <= 1100px) {
|
||||
.bs-sheet__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bs-col {
|
||||
padding: 0;
|
||||
|
||||
&:first-child {
|
||||
border-right: none;
|
||||
border-bottom: 1px dotted $rule;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Print: only the sheet, at report proportions ───────────────────────────
|
||||
@media print {
|
||||
.bs-controls,
|
||||
.bs-alert,
|
||||
.bs-loading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bs-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bs-sheet {
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bs-sheet__body {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.bs-block {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AccountingService } from '../accounting.service';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { buildBalanceSheetPdf } from './balance-sheet-pdf';
|
||||
|
||||
interface BalanceSheetNode {
|
||||
id: number;
|
||||
name: string;
|
||||
glCode: string;
|
||||
usage: 'HEADER' | 'DETAIL';
|
||||
balance: number;
|
||||
children: BalanceSheetNode[];
|
||||
}
|
||||
|
||||
interface BalanceSheetGroup {
|
||||
classification: string;
|
||||
total: number;
|
||||
nodes: BalanceSheetNode[];
|
||||
}
|
||||
|
||||
interface BalanceSheetSide {
|
||||
label: string;
|
||||
total: number;
|
||||
groups: BalanceSheetGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance
|
||||
* sheet reads: liabilities on the left, assets on the right, each top-level Header account a named
|
||||
* block whose Detail accounts are listed under it and subtotalled in the TOTAL column.
|
||||
*
|
||||
* Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own
|
||||
* postings, and a Header's as the recursive sum of its children (a Header never carries a posting
|
||||
* of its own). A Detail account that sits under no Header at all is shown as its own one-line block
|
||||
* rather than being dropped.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-balance-sheet',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './balance-sheet.component.html',
|
||||
styleUrl: './balance-sheet.component.scss'
|
||||
})
|
||||
export class BalanceSheetComponent implements OnInit {
|
||||
private accountingService = inject(AccountingService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
/** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */
|
||||
bankName = 'Bank';
|
||||
|
||||
asOnDate: string = new Date().toISOString().slice(0, 10);
|
||||
officeId: number | null = null;
|
||||
offices: { id: number; name: string }[] = [];
|
||||
|
||||
loading = false;
|
||||
error = '';
|
||||
asOnDateLoaded = '';
|
||||
|
||||
/** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */
|
||||
private rawDebitSide: BalanceSheetSide | null = null;
|
||||
private rawCreditSide: BalanceSheetSide | null = null;
|
||||
|
||||
debitSide: BalanceSheetSide | null = null;
|
||||
creditSide: BalanceSheetSide | null = null;
|
||||
|
||||
/**
|
||||
* A working chart of accounts carries far more accounts than a bank actually operates, and a
|
||||
* printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes
|
||||
* the sheet unreadable. On by default for that reason; switch it off to audit the full chart.
|
||||
*/
|
||||
hideZeroBalances = true;
|
||||
|
||||
/** Both sides are derived from the same double-entry ledger, so they can only disagree if a
|
||||
* posting is missing a leg — worth surfacing rather than hiding. */
|
||||
get tallied(): boolean {
|
||||
if (!this.debitSide || !this.creditSide) {
|
||||
return true;
|
||||
}
|
||||
return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01;
|
||||
}
|
||||
|
||||
get difference(): number {
|
||||
if (!this.debitSide || !this.creditSide) {
|
||||
return 0;
|
||||
}
|
||||
return Math.abs(this.debitSide.total - this.creditSide.total);
|
||||
}
|
||||
|
||||
get officeLabel(): string {
|
||||
if (this.officeId == null) {
|
||||
return 'ALL BRANCHES';
|
||||
}
|
||||
return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountingService.getOffices().subscribe({
|
||||
next: (offices) => (this.offices = offices || []),
|
||||
error: () => (this.offices = [])
|
||||
});
|
||||
this.organizationService.getInstitutionProfile().subscribe({
|
||||
next: (profile: any) => {
|
||||
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
|
||||
},
|
||||
// A missing institution profile must not stop the sheet from loading — the PDF just carries
|
||||
// the fallback title.
|
||||
error: () => undefined
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({
|
||||
next: (res: any) => {
|
||||
this.rawDebitSide = res.debitSide;
|
||||
this.rawCreditSide = res.creditSide;
|
||||
this.asOnDateLoaded = res.asOnDate;
|
||||
this.applyZeroFilter();
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.error = 'Could not load the balance sheet for this date.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The
|
||||
* totals are deliberately left as the server computed them — hiding a nil line must never change
|
||||
* a figure, and a group total is unaffected by removing children that summed to nothing.
|
||||
*/
|
||||
applyZeroFilter(): void {
|
||||
this.debitSide = this.filterSide(this.rawDebitSide);
|
||||
this.creditSide = this.filterSide(this.rawCreditSide);
|
||||
}
|
||||
|
||||
private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null {
|
||||
if (!side) {
|
||||
return null;
|
||||
}
|
||||
if (!this.hideZeroBalances) {
|
||||
return side;
|
||||
}
|
||||
const groups = side.groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
nodes: group.nodes
|
||||
.map((node) => this.pruneZeros(node))
|
||||
.filter((node): node is BalanceSheetNode => node !== null)
|
||||
}))
|
||||
.filter((group) => group.nodes.length > 0);
|
||||
return { ...side, groups };
|
||||
}
|
||||
|
||||
/** Drops a node whose balance is nil, and any Header left with nothing under it. */
|
||||
private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null {
|
||||
if (Math.abs(node.balance) < 0.005) {
|
||||
return null;
|
||||
}
|
||||
const children = node.children
|
||||
.map((child) => this.pruneZeros(child))
|
||||
.filter((child): child is BalanceSheetNode => child !== null);
|
||||
return { ...node, children };
|
||||
}
|
||||
|
||||
/** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */
|
||||
fmt(value: number): string {
|
||||
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy, as the printed report dates it. */
|
||||
displayDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return '';
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = iso.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the report as a real PDF rather than printing the page: window.print() takes the whole
|
||||
* app shell with it and breaks pages wherever they happen to fall, which is what made the earlier
|
||||
* output unusable.
|
||||
*/
|
||||
downloadPdf(): void {
|
||||
if (!this.debitSide || !this.creditSide) {
|
||||
return;
|
||||
}
|
||||
const doc = buildBalanceSheetPdf({
|
||||
bankName: this.bankName,
|
||||
officeLabel: this.officeLabel,
|
||||
asOnDate: this.asOnDateLoaded || this.asOnDate,
|
||||
creditSide: this.creditSide,
|
||||
debitSide: this.debitSide
|
||||
});
|
||||
doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user