oss file updated, profitl loss as per the day month and also with date select
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data layer for the General Ledger report suite.
|
||||
*
|
||||
* Rather than hard-coding seven finished reports, this file holds a chart of
|
||||
* accounts plus a generated ledger of double-entry postings. Every report is
|
||||
* then computed from those postings, which is what makes the day / month /
|
||||
* branch filters genuinely recompute instead of just re-labelling a static table.
|
||||
*
|
||||
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
|
||||
* figures are identical on every reload and across users. Amounts are in rupees.
|
||||
*/
|
||||
|
||||
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
|
||||
|
||||
export interface GlAccount {
|
||||
code: string;
|
||||
name: string;
|
||||
type: GlAccountType;
|
||||
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
|
||||
group: string;
|
||||
/** Included in the Misc Expense Ledger report. */
|
||||
miscExpense?: boolean;
|
||||
}
|
||||
|
||||
export interface GlBranch {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One side of a double-entry voucher. */
|
||||
export interface GlPosting {
|
||||
/** ISO yyyy-MM-dd — the posting (value) date. */
|
||||
date: string;
|
||||
branchCode: string;
|
||||
accountCode: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
voucherNo: string;
|
||||
narration: string;
|
||||
}
|
||||
|
||||
export const GL_BRANCHES: GlBranch[] = [
|
||||
{ code: 'HO', name: 'Head Office' },
|
||||
{ code: '0002', name: 'Andheri (E)' },
|
||||
{ code: '0003', name: 'Bandra (W)' },
|
||||
{ code: '0004', name: 'Borivali' },
|
||||
{ code: '0005', name: 'Dadar' },
|
||||
{ code: '0006', name: 'Thane' }
|
||||
];
|
||||
|
||||
export const GL_ACCOUNTS: GlAccount[] = [
|
||||
// ── Assets ────────────────────────────────────────────────────────────────
|
||||
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
|
||||
|
||||
// ── Liabilities & Capital ─────────────────────────────────────────────────
|
||||
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
|
||||
// ── Income ────────────────────────────────────────────────────────────────
|
||||
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
|
||||
|
||||
// ── Expenditure ───────────────────────────────────────────────────────────
|
||||
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '430101', name: 'Printing & Stationery', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430102', name: 'Postage, Telegram & Telephone', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430107', name: 'Legal & Professional Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430108', name: 'Insurance (DICGC & Other)', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430109', name: 'Bank Charges & Commission Paid', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{ code: '430112', name: 'Other Miscellaneous Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
|
||||
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
|
||||
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
|
||||
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
|
||||
];
|
||||
|
||||
/** Financial year covered by the generated ledger. */
|
||||
export const GL_PERIOD = {
|
||||
/** FY 2026-27 opens here. */
|
||||
fyStart: '2026-04-01',
|
||||
/** Postings run to the day before the app's "today" (01 Aug 2026). */
|
||||
fyEnd: '2026-07-31',
|
||||
label: 'FY 2026-27'
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
|
||||
* account opens on its natural side (debit for assets/expenses, credit for the
|
||||
* rest). Head Office carries the investments and reserves; branches carry the
|
||||
* customer-facing book.
|
||||
*/
|
||||
const OPENING_BALANCES: Record<string, number> = {
|
||||
'110101': 9_996_000,
|
||||
'110102': 12_400_000,
|
||||
'110103': 13_667_000,
|
||||
// Surplus deposits parked with correspondent banks. Sized so the opening
|
||||
// balance sheet tallies exactly — see the assertion in glPostings().
|
||||
'110104': 54_206_000,
|
||||
'120101': 63_839_000,
|
||||
'120102': 147_895_000,
|
||||
'120201': 22_500_000,
|
||||
'120202': 24_234_000,
|
||||
'130101': 166_614_000,
|
||||
'130102': 125_082_000,
|
||||
'130103': 70_236_000,
|
||||
'130104': 29_356_000,
|
||||
'130105': 10_241_000,
|
||||
'130106': 8_450_000,
|
||||
'140101': 33_541_000,
|
||||
'140102': 3_185_000,
|
||||
'140103': 1_763_000,
|
||||
'140104': 1_240_000,
|
||||
'140105': 657_000,
|
||||
'210101': 27_278_000,
|
||||
'210201': 31_149_000,
|
||||
'210202': 13_642_000,
|
||||
'210203': 3_750_000,
|
||||
'210204': 24_341_000,
|
||||
'220101': 208_116_000,
|
||||
'220102': 19_592_000,
|
||||
'220103': 406_601_000,
|
||||
'220104': 24_880_000,
|
||||
'230101': 731_000,
|
||||
'230102': 291_000,
|
||||
'230103': 5_515_000,
|
||||
'230104': 1_120_000,
|
||||
'230105': 32_096_000,
|
||||
'910101': 1_450_000,
|
||||
'910102': 980_000,
|
||||
'910103': 620_000,
|
||||
'910104': 1_180_000,
|
||||
'910105': 2_638_000,
|
||||
'910106': 0,
|
||||
'910107': 340_000
|
||||
};
|
||||
|
||||
/** Deterministic linear congruential generator — same figures on every load. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
|
||||
export function ledgerDates(): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||
while (cur <= end) {
|
||||
if (cur.getUTCDay() !== 0) {
|
||||
out.push(isoDate(cur));
|
||||
}
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Templates for the vouchers the generator posts each day. */
|
||||
const VOUCHER_TEMPLATES: {
|
||||
debit: string;
|
||||
credit: string;
|
||||
narration: string;
|
||||
/** Rough rupee scale of the voucher. */
|
||||
scale: number;
|
||||
/** Chance the voucher is raised on any given branch-day. */
|
||||
frequency: number;
|
||||
}[] = [
|
||||
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
|
||||
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
|
||||
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
|
||||
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
|
||||
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
|
||||
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
|
||||
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
|
||||
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
|
||||
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
|
||||
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
|
||||
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
|
||||
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
|
||||
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
|
||||
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
|
||||
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
|
||||
// Interest on advances is the dominant earning line — sized so the bank runs a
|
||||
// ~13% net margin rather than the loss the first-cut figures produced.
|
||||
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
|
||||
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
|
||||
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
|
||||
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
|
||||
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
||||
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
||||
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
||||
{ debit: '410101', credit: '230101', narration: 'Interest provided on savings deposits', scale: 128_000, frequency: 0.8 },
|
||||
{ debit: '410102', credit: '230101', narration: 'Interest provided on term deposits', scale: 265_000, frequency: 0.9 },
|
||||
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
||||
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
||||
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
|
||||
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
|
||||
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
|
||||
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
|
||||
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
|
||||
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
|
||||
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
||||
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
||||
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
||||
{ debit: '110102', credit: '110101', narration: 'Cash remitted to RBI currency chest', scale: 1_800_000, frequency: 0.2 },
|
||||
{ debit: '110101', credit: '110103', narration: 'Funds drawn from correspondent bank', scale: 1_400_000, frequency: 0.2 }
|
||||
];
|
||||
|
||||
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
||||
{ debit: '120102', credit: '110102', narration: 'Purchase of State Govt securities', scale: 1_900_000, frequency: 0.12 },
|
||||
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
||||
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
||||
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
|
||||
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
|
||||
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
|
||||
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
|
||||
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Contingent-account movements are memoranda, each posted single-sided.
|
||||
* Guarantees, LCs and collection bills are raised at the counter, so these run
|
||||
* at every branch — not just Head Office.
|
||||
*/
|
||||
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
|
||||
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
|
||||
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
|
||||
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
|
||||
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
|
||||
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
|
||||
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
|
||||
];
|
||||
|
||||
/**
|
||||
* How the opening contingent book is spread across the network. Head Office
|
||||
* carries the capital commitments and large claims; the branches carry the
|
||||
* counter-issued guarantees. Weights are applied per account and the rounding
|
||||
* remainder is absorbed by Head Office so each account's total stays exact.
|
||||
*/
|
||||
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
|
||||
HO: 0.2,
|
||||
'0002': 0.18,
|
||||
'0003': 0.16,
|
||||
'0004': 0.16,
|
||||
'0005': 0.15,
|
||||
'0006': 0.15
|
||||
};
|
||||
|
||||
let cachedPostings: GlPosting[] | null = null;
|
||||
|
||||
/**
|
||||
* The full generated ledger. Built once and memoised — every report reads from
|
||||
* this array, so the reports can never disagree with one another.
|
||||
*/
|
||||
export function glPostings(): GlPosting[] {
|
||||
if (cachedPostings) {
|
||||
return cachedPostings;
|
||||
}
|
||||
const rand = lcg(20260401);
|
||||
const out: GlPosting[] = [];
|
||||
const dates = ledgerDates();
|
||||
let voucherSeq = 1;
|
||||
|
||||
// Opening entries, posted at Head Office on day one so the books start in balance.
|
||||
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
|
||||
let openingDebit = 0;
|
||||
let openingCredit = 0;
|
||||
|
||||
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const account = GL_ACCOUNTS.find((a) => a.code === code);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
|
||||
// Contingent accounts are single-sided memoranda held at the issuing branch,
|
||||
// so their opening book is spread across the network rather than sat at HO.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
let allocated = 0;
|
||||
for (const branch of GL_BRANCHES) {
|
||||
if (branch.code === 'HO') {
|
||||
continue;
|
||||
}
|
||||
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
|
||||
if (!share) {
|
||||
continue;
|
||||
}
|
||||
allocated += share;
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: branch.code,
|
||||
accountCode: code,
|
||||
debit: share,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
}
|
||||
// Head Office takes its own share plus whatever rounding left behind.
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: amount - allocated,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: debitSide ? amount : 0,
|
||||
credit: debitSide ? 0 : amount,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
if (debitSide) {
|
||||
openingDebit += amount;
|
||||
} else {
|
||||
openingCredit += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
|
||||
// in a visibly-named suspense account rather than letting the trial balance drift.
|
||||
const openingResidual = openingCredit - openingDebit;
|
||||
if (openingResidual !== 0) {
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: '140199',
|
||||
debit: openingResidual > 0 ? openingResidual : 0,
|
||||
credit: openingResidual < 0 ? -openingResidual : 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Unreconciled opening balance difference'
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n: number): number => Math.round(n / 100) * 100;
|
||||
|
||||
for (const date of dates) {
|
||||
for (const branch of GL_BRANCHES) {
|
||||
// Head Office posts fewer counter transactions than the branches.
|
||||
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (rand() > tpl.frequency * weight) {
|
||||
continue;
|
||||
}
|
||||
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.debit,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.credit,
|
||||
debit: 0,
|
||||
credit: amount,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
|
||||
// Contingent memoranda are raised at every branch, weighted by its size.
|
||||
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
|
||||
for (const tpl of CONTINGENT_TEMPLATES) {
|
||||
// Claims against the bank and capital commitments are Head Office matters.
|
||||
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
|
||||
if (headOfficeOnly && branch.code !== 'HO') {
|
||||
continue;
|
||||
}
|
||||
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
|
||||
if (rand() > frequency) {
|
||||
continue;
|
||||
}
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.account,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedPostings = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function glAccount(code: string): GlAccount | undefined {
|
||||
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||
}
|
||||
|
||||
export function glBranchName(code: string): string {
|
||||
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'gl/reports',
|
||||
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
},
|
||||
{
|
||||
path: 'gl/reports/:report',
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class GlReportsRoutingModule {}
|
||||
@@ -0,0 +1,523 @@
|
||||
<!--
|
||||
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="glr-root">
|
||||
<!-- HEADER -->
|
||||
<div class="glr-head">
|
||||
<div class="glr-head-left">
|
||||
<fa-icon icon="book-open" class="glr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="glr-head-t">General Ledger — {{ reportLabel }}</h2>
|
||||
<div class="glr-head-s">
|
||||
CBSNexora Bank Ltd · {{ branchLabel }} · Period <strong>{{ rangeLabel }}</strong> ·
|
||||
{{ ledgerPeriod.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="glr-btn glr-btn-xls" [disabled]="generating" (click)="exportExcel()">
|
||||
<fa-icon icon="file-csv"></fa-icon>
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FILTER BAR -->
|
||||
<div class="glr-filters">
|
||||
<div class="glr-filter">
|
||||
<label>Period</label>
|
||||
<div class="glr-seg">
|
||||
<button [class.active]="periodMode === 'day'" (click)="periodMode = 'day'">Day-wise</button>
|
||||
<button [class.active]="periodMode === 'month'" (click)="periodMode = 'month'">Month-wise</button>
|
||||
<button [class.active]="periodMode === 'range'" (click)="periodMode = 'range'">Date Range</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (periodMode === 'day') {
|
||||
<div class="glr-filter">
|
||||
<label>Date</label>
|
||||
<input
|
||||
class="glr-inp"
|
||||
type="date"
|
||||
[(ngModel)]="filterDay"
|
||||
[min]="ledgerPeriod.fyStart"
|
||||
[max]="ledgerPeriod.fyEnd"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (periodMode === 'month') {
|
||||
<div class="glr-filter">
|
||||
<label>Month</label>
|
||||
<input class="glr-inp" type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
|
||||
</div>
|
||||
}
|
||||
@if (periodMode === 'range') {
|
||||
<div class="glr-filter">
|
||||
<label>From</label>
|
||||
<input
|
||||
class="glr-inp"
|
||||
type="date"
|
||||
[(ngModel)]="filterFrom"
|
||||
[min]="ledgerPeriod.fyStart"
|
||||
[max]="ledgerPeriod.fyEnd"
|
||||
/>
|
||||
</div>
|
||||
<div class="glr-filter">
|
||||
<label>To</label>
|
||||
<input
|
||||
class="glr-inp"
|
||||
type="date"
|
||||
[(ngModel)]="filterTo"
|
||||
[min]="ledgerPeriod.fyStart"
|
||||
[max]="ledgerPeriod.fyEnd"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="glr-filter">
|
||||
<label>Branch</label>
|
||||
<select class="glr-inp" [(ngModel)]="branchCode">
|
||||
<option value="">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="glr-btn" (click)="resetFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- REPORT SELECTOR -->
|
||||
<div class="glr-tabs">
|
||||
@for (r of reports; track r.code) {
|
||||
<button class="glr-tab" [class.active]="activeReport === r.code" (click)="showReport(r.code)">
|
||||
<fa-icon [icon]="r.icon"></fa-icon> {{ r.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ══════════════ TRIAL BALANCE ══════════════ -->
|
||||
@if (activeReport === 'trial-balance') {
|
||||
<div class="glr-section">
|
||||
<div class="glr-balance" [class.glr-balance--bad]="!tbBalanced">
|
||||
<fa-icon [icon]="tbBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
<span>
|
||||
Closing Dr <strong>₹{{ tbClosingDebit | number: '1.2-2' }}</strong> · Closing Cr
|
||||
<strong>₹{{ tbClosingCredit | number: '1.2-2' }}</strong> —
|
||||
{{ tbBalanced ? 'trial balance tallies' : 'OUT OF BALANCE — investigate before closing the period' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Group</th>
|
||||
<th class="ta-r">Opening Dr</th>
|
||||
<th class="ta-r">Opening Cr</th>
|
||||
<th class="ta-r">Period Dr</th>
|
||||
<th class="ta-r">Period Cr</th>
|
||||
<th class="ta-r">Closing Dr</th>
|
||||
<th class="ta-r">Closing Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of data.trialBalance; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.group }}</td>
|
||||
<td class="ta-r mono">{{ r.openingDebit ? (r.openingDebit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.openingCredit ? (r.openingCredit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.periodDebit ? (r.periodDebit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.periodCredit ? (r.periodCredit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.closingDebit ? (r.closingDebit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.closingCredit ? (r.closingCredit | number: '1.2-2') : '' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="3">TOTAL ({{ data.trialBalance.length }} accounts)</td>
|
||||
<td class="ta-r mono">{{ tbOpeningDebit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ tbOpeningCredit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ tbPeriodDebit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ tbPeriodCredit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ tbClosingDebit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ tbClosingCredit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ PROFIT AND LOSS ══════════════ -->
|
||||
@if (activeReport === 'profit-and-loss') {
|
||||
<div class="glr-section">
|
||||
<div class="glr-kpis">
|
||||
<div class="glr-kpi glr-kpi--teal">
|
||||
<div class="glr-kpi-n">₹{{ totalIncome | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">Total Income</div>
|
||||
</div>
|
||||
<div class="glr-kpi glr-kpi--amber">
|
||||
<div class="glr-kpi-n">₹{{ totalExpense | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">Total Expenditure</div>
|
||||
</div>
|
||||
<div class="glr-kpi" [class.glr-kpi--green]="netProfit >= 0" [class.glr-kpi--red]="netProfit < 0">
|
||||
<div class="glr-kpi-n">₹{{ netProfit | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">{{ netProfit >= 0 ? 'Net Profit' : 'Net Loss' }}</div>
|
||||
<div class="glr-kpi-sub">{{ netProfitPct | number: '1.2-2' }}% of income</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glr-two-col">
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">Expenditure</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of pnlExpense; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="glr-empty">No expenditure posted in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Expenditure</td>
|
||||
<td class="ta-r mono">{{ totalExpense | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (netProfit >= 0) {
|
||||
<tr class="total-row glr-profit-row">
|
||||
<td colspan="2">Net Profit carried to Balance Sheet</td>
|
||||
<td class="ta-r mono">{{ netProfit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">Income</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of pnlIncome; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="glr-empty">No income posted in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Income</td>
|
||||
<td class="ta-r mono">{{ totalIncome | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (netProfit < 0) {
|
||||
<tr class="total-row glr-loss-row">
|
||||
<td colspan="2">Net Loss carried to Balance Sheet</td>
|
||||
<td class="ta-r mono">{{ -netProfit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ CONSOLIDATED BALANCE SHEET ══════════════ -->
|
||||
@if (activeReport === 'balance-sheet') {
|
||||
<div class="glr-section">
|
||||
<div class="glr-balance" [class.glr-balance--bad]="!bsBalanced">
|
||||
<fa-icon [icon]="bsBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
<span>
|
||||
Total Assets <strong>₹{{ bsTotalAssets | number: '1.2-2' }}</strong> · Total Liabilities
|
||||
<strong>₹{{ bsTotalLiabilities | number: '1.2-2' }}</strong> —
|
||||
{{ bsBalanced ? 'balance sheet tallies' : 'OUT OF BALANCE' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glr-two-col">
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">Capital & Liabilities — as at {{ range.to }}</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bsLiabilities; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="mono">—</td>
|
||||
<td>Surplus for the period<span class="muted"> · Profit & Loss</span></td>
|
||||
<td class="ta-r mono">{{ bsSurplus | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Capital & Liabilities</td>
|
||||
<td class="ta-r mono">{{ bsTotalLiabilities | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">Assets — as at {{ range.to }}</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bsAssets; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total Assets</td>
|
||||
<td class="ta-r mono">{{ bsTotalAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ COMPARATIVE BALANCE SHEET ══════════════ -->
|
||||
@if (activeReport === 'comparative-balance-sheet') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
|
||||
this period began. Change the filter above to compare a different span.
|
||||
</p>
|
||||
|
||||
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">{{ block.title }}</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th class="ta-r">Current (₹)</th>
|
||||
<th class="ta-r">Previous (₹)</th>
|
||||
<th class="ta-r">Variance (₹)</th>
|
||||
<th class="ta-r">Variance %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of block.rows; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
|
||||
{{ r.variance | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
|
||||
{{ r.variancePct === null ? '—' : (r.variancePct | number: '1.2-2') + '%' }}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="glr-empty">Nothing to compare for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ CONTINGENT LIABILITIES ══════════════ -->
|
||||
@if (activeReport === 'contingent-liabilities') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
|
||||
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Particulars</th>
|
||||
<th>Group</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of data.contingent; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.group }}</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="glr-empty">No contingent exposures outstanding for this selection.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Total Contingent Liabilities</td>
|
||||
<td class="ta-r mono">{{ contingentTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ GL WISE DR / CR ══════════════ -->
|
||||
@if (activeReport === 'gl-wise-dr-cr') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
|
||||
voucher is double-entry, so the debit and credit totals below must match.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th>Group</th>
|
||||
<th class="ta-r">Debit (₹)</th>
|
||||
<th class="ta-r">Credit (₹)</th>
|
||||
<th class="ta-r">Net (Dr − Cr)</th>
|
||||
<th class="ta-c">Vouchers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of data.glWise; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>
|
||||
<span class="type-pill" [class]="'type-' + r.type.toLowerCase()">{{ r.type }}</span>
|
||||
</td>
|
||||
<td class="muted">{{ r.group }}</td>
|
||||
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono" [class.var-up]="r.net > 0" [class.var-down]="r.net < 0">
|
||||
{{ r.net | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-c mono">{{ r.vouchers }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="glr-empty">No postings for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="4">TOTAL ({{ data.glWise.length }} accounts)</td>
|
||||
<td class="ta-r mono">{{ glWiseDebit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ glWiseCredit | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ glWiseDebit - glWiseCredit | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ MISC EXPENSE LEDGER ══════════════ -->
|
||||
@if (activeReport === 'misc-expense-ledger') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
|
||||
— {{ miscRows.length | number }} entries.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Branch</th>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Narration</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of miscRows; track r.voucherNo + r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.date }}</td>
|
||||
<td class="mono">{{ r.voucherNo }}</td>
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.narration }}</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="glr-empty">No miscellaneous expenses posted for this selection.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="6">Total Miscellaneous Expenses</td>
|
||||
<td class="ta-r mono">{{ miscTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--g-navy: #1a237e;
|
||||
--g-blue: #1d4ed8;
|
||||
--g-blue-l: #eff6ff;
|
||||
--g-teal: #0e7e6b;
|
||||
--g-teal-l: #e0f4f0;
|
||||
--g-green: #15803d;
|
||||
--g-green-l: #dcfce7;
|
||||
--g-amber: #b45309;
|
||||
--g-amber-l: #fef3c7;
|
||||
--g-red: #c0392b;
|
||||
--g-red-l: #fdecea;
|
||||
--g-g50: #f8fafc;
|
||||
--g-g100: #f1f5f9;
|
||||
--g-g200: #e2e8f0;
|
||||
--g-g300: #cbd5e1;
|
||||
--g-g500: #64748b;
|
||||
--g-g700: #334155;
|
||||
--g-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family:
|
||||
'DM Sans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--g-g50);
|
||||
color: var(--g-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.glr-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.glr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.glr-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.glr-head-ic {
|
||||
font-size: 28px;
|
||||
color: var(--g-navy);
|
||||
}
|
||||
|
||||
.glr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glr-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.glr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--g-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--g-g50);
|
||||
border-color: var(--g-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-btn-xls {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTER BAR */
|
||||
.glr-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.glr-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-inp {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
min-width: 190px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--g-navy);
|
||||
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Day / Month / Range segmented control */
|
||||
.glr-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
+ button {
|
||||
border-left: 1px solid var(--g-g200);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* REPORT SELECTOR */
|
||||
.glr-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.glr-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* BALANCE BANNER */
|
||||
.glr-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--g-green-l);
|
||||
border: 1px solid #86efac;
|
||||
color: #14532d;
|
||||
font-size: 13px;
|
||||
|
||||
&--bad {
|
||||
background: var(--g-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.glr-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.glr-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-left: 4px solid var(--g-g300);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
|
||||
&--teal {
|
||||
border-left-color: var(--g-teal);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--g-amber);
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-left-color: var(--g-green);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-kpi-n {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
.glr-kpi-l {
|
||||
font-size: 12px;
|
||||
color: var(--g-g700);
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.glr-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* TABLES */
|
||||
.glr-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.glr-tbl-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
|
||||
&--tall {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-tbl-h {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-navy);
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.glr-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--g-g50);
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--g-g100);
|
||||
font-size: 13px;
|
||||
color: var(--g-g700);
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.total-row td {
|
||||
background: var(--g-g100) !important;
|
||||
font-weight: 700;
|
||||
color: var(--g-g900);
|
||||
border-top: 2px solid var(--g-g300);
|
||||
}
|
||||
|
||||
.glr-profit-row td {
|
||||
background: var(--g-green-l) !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.glr-loss-row td {
|
||||
background: var(--g-red-l) !important;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--g-g500);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.var-up {
|
||||
color: var(--g-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.var-down {
|
||||
color: var(--g-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.glr-empty {
|
||||
text-align: center;
|
||||
padding: 34px;
|
||||
color: var(--g-g500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Account-type pills on the GL Wise report */
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--g-g100);
|
||||
color: var(--g-g700);
|
||||
|
||||
&.type-asset {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-blue);
|
||||
}
|
||||
|
||||
&.type-liability {
|
||||
background: var(--g-teal-l);
|
||||
color: var(--g-teal);
|
||||
}
|
||||
|
||||
&.type-income {
|
||||
background: var(--g-green-l);
|
||||
color: var(--g-green);
|
||||
}
|
||||
|
||||
&.type-expense {
|
||||
background: var(--g-amber-l);
|
||||
color: var(--g-amber);
|
||||
}
|
||||
|
||||
&.type-contingent {
|
||||
background: var(--g-red-l);
|
||||
color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.glr-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.glr-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.glr-kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
|
||||
import {
|
||||
BalanceSheetRow,
|
||||
ComparativeRow,
|
||||
GlDateRange,
|
||||
GlReportSet,
|
||||
MiscExpenseRow,
|
||||
PnlRow,
|
||||
buildReports,
|
||||
clampToLedger,
|
||||
sum
|
||||
} from './gl-reports.compute';
|
||||
|
||||
export type GlReportCode =
|
||||
| 'trial-balance'
|
||||
| 'profit-and-loss'
|
||||
| 'balance-sheet'
|
||||
| 'comparative-balance-sheet'
|
||||
| 'contingent-liabilities'
|
||||
| 'gl-wise-dr-cr'
|
||||
| 'misc-expense-ledger';
|
||||
|
||||
type PeriodMode = 'day' | 'month' | 'range';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-gl-reports',
|
||||
templateUrl: './gl-reports.component.html',
|
||||
styleUrls: ['./gl-reports.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class GlReportsComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
|
||||
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
|
||||
{ code: 'profit-and-loss', label: 'Profit and Loss', icon: 'chart-line' },
|
||||
{ code: 'balance-sheet', label: 'Consolidated Balance Sheet', icon: 'file-alt' },
|
||||
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
|
||||
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
|
||||
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
|
||||
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
|
||||
];
|
||||
|
||||
activeReport: GlReportCode = 'trial-balance';
|
||||
|
||||
branches = GL_BRANCHES;
|
||||
ledgerPeriod = GL_PERIOD;
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
periodMode: PeriodMode = 'month';
|
||||
/** Last full month of the generated ledger. */
|
||||
filterDay = '2026-07-31';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = GL_PERIOD.fyEnd;
|
||||
branchCode = '';
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
|
||||
if (param && this.reports.some((r) => r.code === param)) {
|
||||
this.activeReport = param;
|
||||
}
|
||||
}
|
||||
|
||||
showReport(code: GlReportCode): void {
|
||||
this.activeReport = code;
|
||||
// Keep the URL deep-linkable so the GL menu items land on the right report.
|
||||
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
|
||||
}
|
||||
|
||||
get reportLabel(): string {
|
||||
return this.reports.find((r) => r.code === this.activeReport)?.label ?? '';
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
/** Turns the filter controls into the concrete date range the reports use. */
|
||||
get range(): GlDateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return clampToLedger({ from: this.filterDay, to: this.filterDay });
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = this.filterMonth.split('-').map(Number);
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return clampToLedger({ from: `${this.filterMonth}-01`, to: `${this.filterMonth}-${`${last}`.padStart(2, '0')}` });
|
||||
}
|
||||
return clampToLedger({ from: this.filterFrom, to: this.filterTo });
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'month';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterDay = '2026-07-31';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = GL_PERIOD.fyEnd;
|
||||
this.branchCode = '';
|
||||
}
|
||||
|
||||
/** All seven reports for the current filter — memoised inside buildReports. */
|
||||
get data(): GlReportSet {
|
||||
return buildReports(this.range, this.branchCode);
|
||||
}
|
||||
|
||||
// ── Trial Balance ──────────────────────────────────────────────────────────
|
||||
get tbOpeningDebit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.openingDebit);
|
||||
}
|
||||
get tbOpeningCredit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.openingCredit);
|
||||
}
|
||||
get tbPeriodDebit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.periodDebit);
|
||||
}
|
||||
get tbPeriodCredit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.periodCredit);
|
||||
}
|
||||
get tbClosingDebit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.closingDebit);
|
||||
}
|
||||
get tbClosingCredit(): number {
|
||||
return sum(this.data.trialBalance, (r) => r.closingCredit);
|
||||
}
|
||||
/** A trial balance that does not tally is the single most important thing to surface. */
|
||||
get tbBalanced(): boolean {
|
||||
return Math.round(this.tbClosingDebit) === Math.round(this.tbClosingCredit);
|
||||
}
|
||||
|
||||
// ── Profit and Loss ────────────────────────────────────────────────────────
|
||||
get pnlIncome(): PnlRow[] {
|
||||
return this.data.pnl.income;
|
||||
}
|
||||
get pnlExpense(): PnlRow[] {
|
||||
return this.data.pnl.expense;
|
||||
}
|
||||
get totalIncome(): number {
|
||||
return sum(this.pnlIncome, (r) => r.amount);
|
||||
}
|
||||
get totalExpense(): number {
|
||||
return sum(this.pnlExpense, (r) => r.amount);
|
||||
}
|
||||
get netProfit(): number {
|
||||
return this.totalIncome - this.totalExpense;
|
||||
}
|
||||
get netProfitPct(): number {
|
||||
return this.totalIncome ? (this.netProfit / this.totalIncome) * 100 : 0;
|
||||
}
|
||||
|
||||
// ── Balance Sheet ──────────────────────────────────────────────────────────
|
||||
get bsAssets(): BalanceSheetRow[] {
|
||||
return this.data.balanceSheet.assets;
|
||||
}
|
||||
get bsLiabilities(): BalanceSheetRow[] {
|
||||
return this.data.balanceSheet.liabilities;
|
||||
}
|
||||
get bsSurplus(): number {
|
||||
return this.data.balanceSheet.surplus;
|
||||
}
|
||||
get bsTotalAssets(): number {
|
||||
return sum(this.bsAssets, (r) => r.amount);
|
||||
}
|
||||
get bsTotalLiabilities(): number {
|
||||
return sum(this.bsLiabilities, (r) => r.amount) + this.bsSurplus;
|
||||
}
|
||||
get bsBalanced(): boolean {
|
||||
return Math.round(this.bsTotalAssets) === Math.round(this.bsTotalLiabilities);
|
||||
}
|
||||
|
||||
// ── Comparative Balance Sheet ──────────────────────────────────────────────
|
||||
get compAssets(): ComparativeRow[] {
|
||||
return this.data.comparative.filter((r) => r.side === 'ASSET');
|
||||
}
|
||||
get compLiabilities(): ComparativeRow[] {
|
||||
return this.data.comparative.filter((r) => r.side === 'LIABILITY');
|
||||
}
|
||||
|
||||
// ── Contingent Liabilities ─────────────────────────────────────────────────
|
||||
get contingentTotal(): number {
|
||||
return sum(this.data.contingent, (r) => r.amount);
|
||||
}
|
||||
|
||||
// ── GL Wise DR/CR ──────────────────────────────────────────────────────────
|
||||
get glWiseDebit(): number {
|
||||
return sum(this.data.glWise, (r) => r.debit);
|
||||
}
|
||||
get glWiseCredit(): number {
|
||||
return sum(this.data.glWise, (r) => r.credit);
|
||||
}
|
||||
|
||||
// ── Misc Expense Ledger ────────────────────────────────────────────────────
|
||||
get miscRows(): MiscExpenseRow[] {
|
||||
return this.data.miscExpense;
|
||||
}
|
||||
get miscTotal(): number {
|
||||
return sum(this.miscRows, (r) => r.amount);
|
||||
}
|
||||
|
||||
branchName(code: string): string {
|
||||
return glBranchName(code);
|
||||
}
|
||||
|
||||
// ── Excel export ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Exports whichever report is on screen, carrying the active filter into the header. */
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
const sheet = wb.addWorksheet(this.reportLabel.slice(0, 31));
|
||||
|
||||
const { headers, rows, widths } = this.exportShape();
|
||||
sheet.columns = widths.map((w) => ({ width: w }));
|
||||
|
||||
const title = sheet.addRow([this.reportLabel]);
|
||||
sheet.mergeCells(title.number, 1, title.number, headers.length);
|
||||
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
title.height = 24;
|
||||
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
|
||||
|
||||
const meta = sheet.addRow([`Period: ${this.rangeLabel} · Branch: ${this.branchLabel}`]);
|
||||
sheet.mergeCells(meta.number, 1, meta.number, headers.length);
|
||||
meta.font = { italic: true, size: 10 };
|
||||
sheet.addRow([]);
|
||||
|
||||
const head = sheet.addRow(headers);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||
head.eachCell((c) => {
|
||||
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||
c.border = { bottom: { style: 'thin' } };
|
||||
});
|
||||
|
||||
for (const r of rows) {
|
||||
const row = sheet.addRow(r);
|
||||
for (let c = 1; c <= headers.length; c++) {
|
||||
if (typeof r[c - 1] === 'number') {
|
||||
row.getCell(c).numFmt = '#,##0.00';
|
||||
}
|
||||
}
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
|
||||
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 = `${this.reportLabel.replace(/[^\w]+/g, '-')}-${this.range.from}-to-${this.range.to}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
this.toast(`${this.reportLabel} exported for ${this.rangeLabel}`);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Header row, data rows and column widths for the active report. */
|
||||
private exportShape(): { headers: string[]; rows: (string | number | null)[][]; widths: number[] } {
|
||||
switch (this.activeReport) {
|
||||
case 'trial-balance':
|
||||
return {
|
||||
headers: [
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Group',
|
||||
'Opening Dr',
|
||||
'Opening Cr',
|
||||
'Period Dr',
|
||||
'Period Cr',
|
||||
'Closing Dr',
|
||||
'Closing Cr'
|
||||
],
|
||||
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
|
||||
rows: [
|
||||
...this.data.trialBalance.map((r) => [
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.openingDebit,
|
||||
r.openingCredit,
|
||||
r.periodDebit,
|
||||
r.periodCredit,
|
||||
r.closingDebit,
|
||||
r.closingCredit
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
this.tbOpeningDebit,
|
||||
this.tbOpeningCredit,
|
||||
this.tbPeriodDebit,
|
||||
this.tbPeriodCredit,
|
||||
this.tbClosingDebit,
|
||||
this.tbClosingCredit
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'profit-and-loss':
|
||||
return {
|
||||
headers: [
|
||||
'Side',
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 12, 42, 26, 18],
|
||||
rows: [
|
||||
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
|
||||
['Total Expenditure', '', '', '', this.totalExpense],
|
||||
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
|
||||
['Total Income', '', '', '', this.totalIncome],
|
||||
['Net Profit', '', '', '', this.netProfit]
|
||||
]
|
||||
};
|
||||
case 'balance-sheet':
|
||||
return {
|
||||
headers: [
|
||||
'Side',
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 12, 42, 26, 18],
|
||||
rows: [
|
||||
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
|
||||
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
|
||||
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
|
||||
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
|
||||
['Total Assets', '', '', '', this.bsTotalAssets]
|
||||
]
|
||||
};
|
||||
case 'comparative-balance-sheet':
|
||||
return {
|
||||
headers: [
|
||||
'Side',
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Current',
|
||||
'Previous',
|
||||
'Variance',
|
||||
'Variance %'
|
||||
],
|
||||
widths: [12, 12, 42, 18, 18, 18, 14],
|
||||
rows: this.data.comparative.map((r) => [
|
||||
r.side,
|
||||
r.code,
|
||||
r.name,
|
||||
r.current,
|
||||
r.previous,
|
||||
r.variance,
|
||||
r.variancePct === null ? '—' : Number(r.variancePct.toFixed(2))
|
||||
])
|
||||
};
|
||||
case 'contingent-liabilities':
|
||||
return {
|
||||
headers: [
|
||||
'GL Code',
|
||||
'Particulars',
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [12, 48, 26, 18],
|
||||
rows: [
|
||||
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
|
||||
['TOTAL', '', '', this.contingentTotal]
|
||||
]
|
||||
};
|
||||
case 'gl-wise-dr-cr':
|
||||
return {
|
||||
headers: [
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Type',
|
||||
'Group',
|
||||
'Debit',
|
||||
'Credit',
|
||||
'Net (Dr-Cr)',
|
||||
'Vouchers'
|
||||
],
|
||||
widths: [12, 42, 14, 26, 18, 18, 18, 12],
|
||||
rows: [
|
||||
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
|
||||
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
|
||||
]
|
||||
};
|
||||
case 'misc-expense-ledger':
|
||||
default:
|
||||
return {
|
||||
headers: [
|
||||
'Date',
|
||||
'Voucher No',
|
||||
'Branch',
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Narration',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 20, 18, 12, 36, 46, 18],
|
||||
rows: [
|
||||
...this.miscRows.map((r) => [
|
||||
r.date,
|
||||
r.voucherNo,
|
||||
this.branchName(r.branchCode),
|
||||
r.code,
|
||||
r.name,
|
||||
r.narration,
|
||||
r.amount
|
||||
]),
|
||||
['TOTAL', '', '', '', '', '', this.miscTotal]
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives all seven General Ledger reports from the posting ledger.
|
||||
*
|
||||
* Each report is a pure function of (postings, date range, branch), so changing
|
||||
* the day / month / branch filter genuinely recomputes the figures. Results are
|
||||
* memoised per filter key because the ledger runs to roughly twenty thousand
|
||||
* postings and Angular re-evaluates template getters on every change detection.
|
||||
*/
|
||||
|
||||
import {
|
||||
GL_ACCOUNTS,
|
||||
GL_PERIOD,
|
||||
GlAccount,
|
||||
GlPosting,
|
||||
glAccount,
|
||||
glPostings
|
||||
} from './gl-reports-data';
|
||||
|
||||
export interface GlDateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TrialBalanceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface PnlRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface BalanceSheetRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ComparativeRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
side: 'ASSET' | 'LIABILITY';
|
||||
current: number;
|
||||
previous: number;
|
||||
variance: number;
|
||||
variancePct: number | null;
|
||||
}
|
||||
|
||||
export interface GlWiseRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
type: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
net: number;
|
||||
vouchers: number;
|
||||
}
|
||||
|
||||
export interface MiscExpenseRow {
|
||||
date: string;
|
||||
voucherNo: string;
|
||||
branchCode: string;
|
||||
code: string;
|
||||
name: string;
|
||||
narration: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Natural side of an account — assets and expenses sit on the debit side. */
|
||||
function isDebitSide(account: GlAccount): boolean {
|
||||
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
}
|
||||
|
||||
function matchesBranch(p: GlPosting, branchCode: string): boolean {
|
||||
return !branchCode || p.branchCode === branchCode;
|
||||
}
|
||||
|
||||
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
||||
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date > to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
const account = glAccount(p.accountCode);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Debit and credit turnover strictly inside the range. */
|
||||
function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
||||
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
let entry = out.get(p.accountCode);
|
||||
if (!entry) {
|
||||
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
|
||||
out.set(p.accountCode, entry);
|
||||
}
|
||||
entry.debit += p.debit;
|
||||
entry.credit += p.credit;
|
||||
entry.vouchers.add(p.voucherNo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The day before `date`, ISO formatted — used for opening balances. */
|
||||
function previousDay(date: string): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: TrialBalanceRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
// Contingent accounts are memoranda and never appear in the trial balance.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
continue;
|
||||
}
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const move = turnover.get(account.code);
|
||||
const periodDebit = move?.debit ?? 0;
|
||||
const periodCredit = move?.credit ?? 0;
|
||||
if (!open && !periodDebit && !periodCredit) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = isDebitSide(account);
|
||||
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
|
||||
const closingSigned = debitSide ? closingNatural : -closingNatural;
|
||||
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
|
||||
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
|
||||
periodDebit,
|
||||
periodCredit,
|
||||
closingDebit: closingSigned > 0 ? closingSigned : 0,
|
||||
closingCredit: closingSigned < 0 ? -closingSigned : 0
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const income: PnlRow[] = [];
|
||||
const expense: PnlRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
|
||||
continue;
|
||||
}
|
||||
const move = turnover.get(account.code);
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
const amount =
|
||||
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
|
||||
(account.type === 'INCOME' ? income : expense).push(row);
|
||||
}
|
||||
return { income, expense };
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance sheet as at the period end. The current-period surplus is folded in as
|
||||
* a liability line so the two sides tally the way the printed statement does.
|
||||
*/
|
||||
export function balanceSheet(range: GlDateRange, branchCode: string): {
|
||||
assets: BalanceSheetRow[];
|
||||
liabilities: BalanceSheetRow[];
|
||||
surplus: number;
|
||||
} {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const assets: BalanceSheetRow[] = [];
|
||||
const liabilities: BalanceSheetRow[] = [];
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const bal = closing.get(account.code) ?? 0;
|
||||
if (account.type === 'INCOME') {
|
||||
income += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'EXPENSE') {
|
||||
expense += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'CONTINGENT' || !bal) {
|
||||
continue;
|
||||
}
|
||||
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
|
||||
(account.type === 'ASSET' ? assets : liabilities).push(row);
|
||||
}
|
||||
return { assets, liabilities, surplus: income - expense };
|
||||
}
|
||||
|
||||
/** Current period against the equivalent span immediately before it. */
|
||||
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
|
||||
const priorTo = previousDay(range.from);
|
||||
const current = cumulativeBalances(range.to, branchCode);
|
||||
const previous = cumulativeBalances(priorTo, branchCode);
|
||||
const rows: ComparativeRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
|
||||
continue;
|
||||
}
|
||||
const cur = current.get(account.code) ?? 0;
|
||||
const prev = previous.get(account.code) ?? 0;
|
||||
if (!cur && !prev) {
|
||||
continue;
|
||||
}
|
||||
const variance = cur - prev;
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
side: account.type,
|
||||
current: cur,
|
||||
previous: prev,
|
||||
variance,
|
||||
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
|
||||
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
|
||||
.filter((r) => r.amount !== 0);
|
||||
}
|
||||
|
||||
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: GlWiseRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const move = turnover.get(account.code);
|
||||
if (!move || (!move.debit && !move.credit)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
type: account.type,
|
||||
debit: move.debit,
|
||||
credit: move.credit,
|
||||
net: move.debit - move.credit,
|
||||
vouchers: move.vouchers.size
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
|
||||
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
|
||||
const rows: MiscExpenseRow[] = [];
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
if (!miscCodes.has(p.accountCode) || !p.debit) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
date: p.date,
|
||||
voucherNo: p.voucherNo,
|
||||
branchCode: p.branchCode,
|
||||
code: p.accountCode,
|
||||
name: glAccount(p.accountCode)?.name ?? p.accountCode,
|
||||
narration: p.narration,
|
||||
amount: p.debit
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
|
||||
}
|
||||
|
||||
// ── Memoisation ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GlReportSet {
|
||||
trialBalance: TrialBalanceRow[];
|
||||
pnl: { income: PnlRow[]; expense: PnlRow[] };
|
||||
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
|
||||
comparative: ComparativeRow[];
|
||||
contingent: BalanceSheetRow[];
|
||||
glWise: GlWiseRow[];
|
||||
miscExpense: MiscExpenseRow[];
|
||||
}
|
||||
|
||||
let cacheKey = '';
|
||||
let cached: GlReportSet | null = null;
|
||||
|
||||
/** All seven reports for one filter selection, computed once per selection. */
|
||||
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
|
||||
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||
if (cached && cacheKey === key) {
|
||||
return cached;
|
||||
}
|
||||
cached = {
|
||||
trialBalance: trialBalance(range, branchCode),
|
||||
pnl: profitAndLoss(range, branchCode),
|
||||
balanceSheet: balanceSheet(range, branchCode),
|
||||
comparative: comparativeBalanceSheet(range, branchCode),
|
||||
contingent: contingentLiabilities(range, branchCode),
|
||||
glWise: glWiseDrCr(range, branchCode),
|
||||
miscExpense: miscExpenseLedger(range, branchCode)
|
||||
};
|
||||
cacheKey = key;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Clamp a range to the generated ledger so filters cannot query empty space. */
|
||||
export function clampToLedger(range: GlDateRange): GlDateRange {
|
||||
return {
|
||||
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
|
||||
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
|
||||
};
|
||||
}
|
||||
|
||||
export function sum<T>(rows: T[], pick: (row: T) => number): number {
|
||||
return rows.reduce((acc, r) => acc + pick(r), 0);
|
||||
}
|
||||
|
||||
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
|
||||
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, T[]>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.group)) {
|
||||
map.set(r.group, []);
|
||||
order.push(r.group);
|
||||
}
|
||||
map.get(r.group)!.push(r);
|
||||
}
|
||||
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { GlReportsRoutingModule } from './gl-reports-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
GlReportsRoutingModule
|
||||
]
|
||||
})
|
||||
export class GlReportsModule {}
|
||||
Reference in New Issue
Block a user