diff --git a/src/app/core/branch-scope.service.ts b/src/app/core/branch-scope.service.ts
new file mode 100644
index 000000000..66da9d3d4
--- /dev/null
+++ b/src/app/core/branch-scope.service.ts
@@ -0,0 +1,98 @@
+/**
+ * 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 { Injectable, inject } from '@angular/core';
+import { AuthenticationService } from './authentication/authentication.service';
+import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
+
+/**
+ * Resolves what branch data the logged-in user is allowed to see.
+ *
+ * Head Office sees every branch and can drill into any one of them; a branch login is
+ * locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
+ * Summary and the NPA report with slightly different branch code lists; this service is
+ * the single place it lives so new reports cannot drift.
+ *
+ * The logged-in office is matched to a branch by name, since session credentials carry
+ * only officeId and officeName. Office names are seeded to match the GL branch names
+ * exactly (see Liquibase 0442).
+ */
+@Injectable({ providedIn: 'root' })
+export class BranchScopeService {
+ private auth = inject(AuthenticationService);
+
+ /** Sentinel used when a branch login cannot be matched to a known branch. */
+ static readonly UNRESOLVED = '__unresolved__';
+
+ get isHeadOffice(): boolean {
+ return this.auth.isHeadOfficeUser();
+ }
+
+ /** The office name on the session, for labelling the scope in report headers. */
+ get officeName(): string {
+ return this.auth.getCredentials()?.officeName || 'Branch Office';
+ }
+
+ /**
+ * Branch code the user is confined to, or null for Head Office.
+ *
+ * Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
+ * branch when the office cannot be matched: showing an unrecognised branch someone
+ * else's figures would be worse than showing them nothing.
+ */
+ get ownBranchCode(): string | null {
+ if (this.isHeadOffice) {
+ return null;
+ }
+ const name = this.officeName.toLowerCase().trim();
+ const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
+ // Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
+ const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
+ return loose?.code ?? BranchScopeService.UNRESOLVED;
+ }
+
+ /** True when the user may choose which branch to view. */
+ get canSelectBranch(): boolean {
+ return this.isHeadOffice;
+ }
+
+ /** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
+ get selectableBranches(): GlBranch[] {
+ return this.isHeadOffice ? GL_BRANCHES : [];
+ }
+
+ /** Branch code a report should start on. '' means all branches consolidated. */
+ get defaultBranchCode(): string {
+ return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
+ }
+
+ /**
+ * Forces a requested branch code into what the user is actually allowed to see.
+ * Call this wherever a branch code reaches a data query, so a stale or hand-edited
+ * selection cannot widen the user's scope.
+ */
+ enforce(requested: string): string {
+ return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
+ }
+
+ /** True when a branch login's office matched no known branch, so it sees no data. */
+ get isUnresolved(): boolean {
+ return this.ownBranchCode === BranchScopeService.UNRESOLVED;
+ }
+
+ /** Human label for the current scope, for report headers. */
+ scopeLabel(selectedCode: string): string {
+ if (!this.isHeadOffice) {
+ return this.officeName;
+ }
+ if (!selectedCode) {
+ return 'All Branches (Consolidated)';
+ }
+ return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
+ }
+}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 9b169d73e..b3948a6a9 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -579,6 +579,10 @@
+
+ Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported + in the CTR either way — this simply confirms no STR referral is needed. +
+- CTR every cash txn ≥ threshold stays in the monthly CTR / TC2 - file · - + STR also flagged as suspicious and escalated to a separate STR - case (see the STR tab) + CTR every cash txn ≥ threshold is reported here regardless of + outcome. Record evidence when the customer produces valid documents — CTR only, no STR · + + STR Send to STR when the source of funds cannot + be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred + transaction stays in the CTR file as well.
} @@ -397,9 +441,7 @@{{ poFindingLabel(s.poFinding) }}
+ Generate the FINnet STR XML, upload it on the FIU-IND portal under + {{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here. +
+| Verification point | +Genuine | +Not satisfied | +
|---|---|---|
|
+ {{ item.label }}
+ {{ item.hint }}
+ |
+
+ |
+
+ |
+
- 6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and - STR tabs, so every file in the bundle covers the same transactions. + 6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR + tabs, so every file in the bundle covers the same transactions.
| + | Acknowledgement | +Form | +Name of declarant | +Account No | +Branch | +Txns | +Rule 114B | +Cash turnover | +Credit | +Debit | +Declared income | +Cash ÷ income | +||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
+ |
+ {{ p.declarant.ackNo }} | ++ + {{ formLabel[p.declarant.formType] }} + + | ++ {{ p.declarant.name }} + @if (p.ctrCount > 0) { + {{ p.ctrCount }} CTR + } + | +{{ p.declarant.accountNo }} | +{{ p.declarant.branchCode }} | +{{ p.txnCount }} | +{{ p.declaredCount }} | +{{ p.cashTotal | number: '1.0-0' }} | +{{ p.totalCredit | number: '1.0-0' }} | +{{ p.totalDebit | number: '1.0-0' }} | +{{ p.declarant.estimatedIncome | number: '1.0-0' }} | ++ {{ p.incomeRatio | number: '1.1-1' }}× + | +||||||||||||||||
|
+
+
+
+
+ {{ p.declarant.fatherName }}
+
+
+ {{ p.declarant.dateOfBirth }}
+
+
+ XXXX XXXX {{ p.declarant.aadhaarLast4 }}
+
+
+ {{ p.declarant.declaredOn }}
+
+
+ {{ p.declarant.address }}
+
+
+ {{ p.declarant.reasonForNoPan }}
+
+
|
+ ||||||||||||||||||||||||||||
| + No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}. + | +||||||||||||||||||||||||||||
| Date | +Txn ID | +Acknowledgement | +Form | +Name of declarant | +Account No | +Branch | +Category | +Mode | +Dr/Cr | +Amount | +Nature of transaction | +Voucher | +Flag | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ t.date }} | +{{ t.txnId }} | +{{ t.ackNo }} | ++ {{ formLabel[t.formType] }} + | +{{ t.declarantName }} | +{{ t.accountNo }} | +{{ t.branchCode }} | ++ {{ t.group }} + | ++ {{ t.mode }} + | +{{ t.drCr }} | +{{ t.amount | number: '1.0-0' }} | +{{ t.nature }} | +{{ t.voucherNo }} | ++ @if (t.ctrReportable) { + CTR + } @else if (t.declarationRequired) { + Rule 114B + } + | +
| + No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}. + | +|||||||||||||
- Compares the closing position as at {{ range.to }} against the position immediately before - this period began. Change the filter above to compare a different span. + Compares the closing position as at {{ range.to }} against the position immediately before this + period began. Change the filter above to compare a different span.
- @for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) { + @for ( + block of [ + { title: 'Capital & Liabilities', rows: compLiabilities }, + { title: 'Assets', rows: compAssets } + ]; + track block.title + ) {| {{ r.code }} | -{{ r.name }} · {{ r.group }} | ++ {{ r.name }} · {{ r.group }} + | {{ r.current | number: '1.2-2' }} | {{ r.previous | number: '1.2-2' }} | 0" [class.var-down]="r.variance < 0">
@@ -379,8 +411,8 @@
@if (activeReport === 'contingent-liabilities') {
- Off-balance-sheet commitments outstanding as at {{ range.to }}. These are memoranda accounts - and are deliberately excluded from the Trial Balance and Balance Sheet totals. + Off-balance-sheet commitments outstanding as at {{ range.to }}. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
+
+ }
+
+
+ @if (activeReport === 'account-wise-balance') {
+ + Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3 + screens using Add / Remove GLs; this is the consolidated view of them, with the value each + mapping currently produces from the ledger. + + +
+
+
+ Mapped return lines ({{ mappingRows.length }})
+
+
+ Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line
+
+
+ }
+
+
+ @if (activeReport === 'distribution-of-balance') {
+ + Closing balance of every account as at {{ range.to }}, broken out by branch — which branch is actually carrying each balance. + +
+
+
+
+ }
+
+
+ @if (activeReport === 'client-wise-balance') {
+ + Composition of the book as at {{ range.to }} for {{ branchLabel }} — how concentrated each side + of the balance sheet is by account group. + + +
+ @for (
+ block of [
+ { title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
+ { title: 'Assets', rows: assetDistribution, total: assetDistTotal }
+ ];
+ track block.title
+ ) {
+
+
+
+ }
+ {{ block.title }}
+
+
+ }
+
+
+ @if (activeReport === 'banker-head') {
+
+
+
+
+
+ ₹{{ clientDeposits | number: '1.2-2' }}
+ Customer Deposits
+
+
+ ₹{{ clientAdvances | number: '1.2-2' }}
+ Customer Advances
+
+
+ {{ clientRows.length | number }}
+ Relationships
+ as at {{ range.to }} · {{ branchLabel }}
+ + Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance + in that head — so this report always reconciles to the GL rather than drifting as a second source. + + +
+
+
+
+ }
+
+
+ @if (activeReport === 'sundry-register') {
+ + Head-wise abstract for {{ rangeLabel }} — the trial balance rolled up to principal heads, with + opening, turnover and closing for each. Debit and credit turnover must agree. + +
+
+
+
+ }
+
+
+ @if (activeReport === 'voucher-register') {
+ + Outstanding items in the sundry, suspense and bills-payable heads as at {{ range.to }}, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of + the register. + + + @if (sundryBuckets.length) { +
+ @for (b of sundryBuckets; track b.bucket) {
+
+ }
+
+
+
+ }
+ ₹{{ b.amount | number: '1.2-2' }}
+ {{ b.bucket }}
+ {{ b.count }} item(s)
+
+
+
+
+ }
+
0">
+
+
+
+
+
{{ toastMsg }}
diff --git a/src/app/gl-reports/gl-reports.component.scss b/src/app/gl-reports/gl-reports.component.scss
index ac1f6bdc9..b5249a0c9 100644
--- a/src/app/gl-reports/gl-reports.component.scss
+++ b/src/app/gl-reports/gl-reports.component.scss
@@ -160,6 +160,21 @@
}
}
+/* Read-only branch shown to a branch-level login in place of the selector. */
+.glr-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 8px 14px;
+ min-width: 190px;
+ border: 1px solid var(--g-g200);
+ border-radius: 8px;
+ background: var(--g-g100);
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--g-g700);
+}
+
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
@@ -434,6 +449,45 @@
font-style: italic;
}
+/* A voucher whose debits and credits disagree. */
+.glr-unbalanced td {
+ background: var(--g-red-l) !important;
+ color: #7f1d1d;
+ font-weight: 600;
+}
+
+/* GL code chips on the mapping report */
+.glr-gl-chip {
+ display: inline-block;
+ padding: 1px 8px;
+ margin: 1px 3px 1px 0;
+ border-radius: 999px;
+ background: #e0f2fe;
+ color: #075985;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+/* Share bars on the distribution report */
+.glr-bar-col {
+ width: 150px;
+}
+
+.glr-bar {
+ height: 9px;
+ border-radius: 999px;
+ background: var(--g-g100);
+ overflow: hidden;
+ min-width: 110px;
+}
+
+.glr-bar-fill {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
+ transition: width 0.25s;
+}
+
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
diff --git a/src/app/gl-reports/gl-reports.component.ts b/src/app/gl-reports/gl-reports.component.ts
index c719c52d5..a2ca62596 100644
--- a/src/app/gl-reports/gl-reports.component.ts
+++ b/src/app/gl-reports/gl-reports.component.ts
@@ -6,24 +6,42 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
+import { BranchScopeService } from 'app/core/branch-scope.service';
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 { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import {
+ AccountBranchRow,
+ AverageBalanceRow,
BalanceSheetRow,
ComparativeRow,
+ DistributionRow,
GlDateRange,
GlReportSet,
MiscExpenseRow,
PnlRow,
+ BankerHeadRow,
+ ClientBalanceRow,
+ SundryItemRow,
+ VoucherRow,
+ accountWiseByBranch,
+ averageBalances,
+ balanceDistribution,
+ bankerHeadSummary,
+ clientWiseBalances,
+ sundryRegister,
+ voucherRegister,
buildReports,
clampToLedger,
+ glValueForCodes,
sum
} from './gl-reports.compute';
+import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
export type GlReportCode =
| 'trial-balance'
@@ -32,7 +50,15 @@ export type GlReportCode =
| 'comparative-balance-sheet'
| 'contingent-liabilities'
| 'gl-wise-dr-cr'
- | 'misc-expense-ledger';
+ | 'misc-expense-ledger'
+ | 'gl-average-balance'
+ | 'gl-mapping'
+ | 'account-wise-balance'
+ | 'distribution-of-balance'
+ | 'client-wise-balance'
+ | 'banker-head'
+ | 'sundry-register'
+ | 'voucher-register';
type PeriodMode = 'day' | 'month' | 'range';
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
+ private scope = inject(BranchScopeService);
+ private destroyRef = inject(DestroyRef);
+
+ /** Head Office may pick any branch; a branch login is locked to its own book. */
+ get canSelectBranch(): boolean {
+ return this.scope.canSelectBranch;
+ }
+ get scopeIsUnresolved(): boolean {
+ return this.scope.isUnresolved;
+ }
+ get scopeName(): string {
+ return this.scope.scopeLabel(this.branchCode);
+ }
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
{ 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' }
+ { code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
+ { code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
+ { code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
+ { code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
+ { code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
+ { code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
+ { code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
+ { code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
+ { code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
];
activeReport: GlReportCode = 'trial-balance';
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
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;
- }
+ // Subscribe rather than read the snapshot: navigating between GL menu items only
+ // changes the route parameter, so Angular reuses this component and ngOnInit would
+ // never fire again — leaving the previously selected report on screen.
+ this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+ const param = params.get('report') as GlReportCode | null;
+ if (param && this.reports.some((r) => r.code === param)) {
+ this.activeReport = param;
+ }
+ });
+ // A branch login opens on its own book and cannot move off it.
+ this.branchCode = this.scope.defaultBranchCode;
}
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 });
+ this.router.navigate(
+ [
+ '/gl/reports',
+ code
+ ],
+ { replaceUrl: true }
+ );
}
get reportLabel(): string {
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
}
get branchLabel(): string {
+ if (!this.canSelectBranch) {
+ return this.scopeName;
+ }
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
+ /**
+ * The branch actually used for every query. Applied at the data boundary rather than
+ * only in the UI so a branch login cannot widen its scope by tampering with state.
+ */
+ private get effectiveBranch(): string {
+ return this.scope.enforce(this.branchCode);
+ }
+
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 = '';
+ // Reset returns a branch login to its own branch, never to "all".
+ this.branchCode = this.scope.defaultBranchCode;
}
/** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet {
- return buildReports(this.range, this.branchCode);
+ return buildReports(this.range, this.effectiveBranch);
}
// ── Trial Balance ──────────────────────────────────────────────────────────
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
return glBranchName(code);
}
+ // ── GL Average Balance ─────────────────────────────────────────────────────
+ get averageRows(): AverageBalanceRow[] {
+ return averageBalances(this.range, this.effectiveBranch);
+ }
+ get avgTotalAverage(): number {
+ return sum(this.averageRows, (r) => r.average);
+ }
+
+ // ── GL Mapping for Report ──────────────────────────────────────────────────
+ /** Every RBI return line currently pinned to GL accounts, with its ledger value. */
+ get mappingRows(): {
+ returnCode: string;
+ returnLabel: string;
+ lineKey: string;
+ codes: string[];
+ codeLabels: string;
+ value: number;
+ }[] {
+ return allMappings()
+ .map((m: GlMappingEntry) => ({
+ returnCode: m.returnCode,
+ returnLabel: returnName(m.returnCode),
+ lineKey: m.lineKey,
+ codes: m.codes,
+ codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
+ // Returns are in thousands; the ledger is in rupees.
+ value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
+ }))
+ .sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
+ }
+
+ /** GL accounts not pinned to any return line — the gap a mapping review looks for. */
+ get unmappedAccounts(): { code: string; name: string; group: string }[] {
+ const used = new Set(allMappings().flatMap((m) => m.codes));
+ return this.chartOfAccounts
+ .filter((a) => !used.has(a.code))
+ .map((a) => ({ code: a.code, name: a.name, group: a.group }));
+ }
+
+ readonly chartOfAccounts = GL_ACCOUNTS;
+
+ // ── Account wise Balance List ──────────────────────────────────────────────
+ /** Branch columns shown — every branch for Head Office, just their own otherwise. */
+ get branchColumns(): string[] {
+ return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
+ }
+ get accountWiseRows(): AccountBranchRow[] {
+ return accountWiseByBranch(this.range, this.branchColumns);
+ }
+ branchColumnTotal(code: string): number {
+ return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
+ }
+ get accountWiseGrandTotal(): number {
+ return sum(this.accountWiseRows, (r) => r.total);
+ }
+
+ // ── Client wise Balance ────────────────────────────────────────────────────
+ get clientRows(): ClientBalanceRow[] {
+ return clientWiseBalances(this.range, this.effectiveBranch);
+ }
+ get clientDeposits(): number {
+ return sum(
+ this.clientRows.filter((r) => r.side === 'DEPOSIT'),
+ (r) => r.balance
+ );
+ }
+ get clientAdvances(): number {
+ return sum(
+ this.clientRows.filter((r) => r.side === 'ADVANCE'),
+ (r) => r.balance
+ );
+ }
+
+ // ── Banker Head Report ─────────────────────────────────────────────────────
+ get bankerHeadRows(): BankerHeadRow[] {
+ return bankerHeadSummary(this.range, this.effectiveBranch);
+ }
+ get headDebitTotal(): number {
+ return sum(this.bankerHeadRows, (r) => r.debit);
+ }
+ get headCreditTotal(): number {
+ return sum(this.bankerHeadRows, (r) => r.credit);
+ }
+
+ // ── Sundry Register ────────────────────────────────────────────────────────
+ get sundryRows(): SundryItemRow[] {
+ return sundryRegister(this.range, this.effectiveBranch);
+ }
+ get sundryTotal(): number {
+ return sum(this.sundryRows, (r) => r.amount);
+ }
+ /** Ageing summary shown above the register. */
+ get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
+ const order = [
+ 'Up to 30 days',
+ '31 to 90 days',
+ '91 to 180 days',
+ 'Over 180 days'
+ ];
+ return order
+ .map((bucket) => {
+ const rows = this.sundryRows.filter((r) => r.bucket === bucket);
+ return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
+ })
+ .filter((b) => b.count > 0);
+ }
+
+ // ── Voucher Register ───────────────────────────────────────────────────────
+ get voucherRows(): VoucherRow[] {
+ return voucherRegister(this.range, this.effectiveBranch);
+ }
+ get voucherTotal(): number {
+ return sum(this.voucherRows, (r) => r.amount);
+ }
+ /** Vouchers whose debits and credits do not agree — should always be zero. */
+ get unbalancedVouchers(): number {
+ return this.voucherRows.filter((r) => !r.balanced).length;
+ }
+
+ // ── Distribution of Balance ────────────────────────────────────────────────
+ get assetDistribution(): DistributionRow[] {
+ return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
+ }
+ get liabilityDistribution(): DistributionRow[] {
+ return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
+ }
+ get assetDistTotal(): number {
+ return sum(this.assetDistribution, (r) => r.amount);
+ }
+ get liabilityDistTotal(): number {
+ return sum(this.liabilityDistribution, (r) => r.amount);
+ }
+
// ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
'Closing Dr',
'Closing Cr'
],
- widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
+ widths: [
+ 12,
+ 42,
+ 26,
+ 16,
+ 16,
+ 16,
+ 16,
+ 16,
+ 16
+ ],
rows: [
...this.data.trialBalance.map((r) => [
r.code,
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
- widths: [14, 12, 42, 26, 18],
+ 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]
+ ...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':
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
- widths: [14, 12, 42, 26, 18],
+ 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]
+ ...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':
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
'Variance',
'Variance %'
],
- widths: [12, 12, 42, 18, 18, 18, 14],
+ widths: [
+ 12,
+ 12,
+ 42,
+ 18,
+ 18,
+ 18,
+ 14
+ ],
rows: this.data.comparative.map((r) => [
r.side,
r.code,
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
'Group',
'Amount'
],
- widths: [12, 48, 26, 18],
+ widths: [
+ 12,
+ 48,
+ 26,
+ 18
+ ],
rows: [
- ...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
- ['TOTAL', '', '', this.contingentTotal]
+ ...this.data.contingent.map((r) => [
+ r.code,
+ r.name,
+ r.group,
+ r.amount
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ this.contingentTotal
+ ]
]
};
case 'gl-wise-dr-cr':
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
'Net (Dr-Cr)',
'Vouchers'
],
- widths: [12, 42, 14, 26, 18, 18, 18, 12],
+ 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]
+ ...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 'gl-average-balance':
+ return {
+ headers: [
+ 'GL Code',
+ 'Account',
+ 'Group',
+ 'Opening',
+ 'Closing',
+ 'Average Daily Balance',
+ 'Peak',
+ 'Lowest',
+ 'Active Days'
+ ],
+ widths: [
+ 12,
+ 42,
+ 26,
+ 18,
+ 18,
+ 22,
+ 18,
+ 18,
+ 12
+ ],
+ rows: [
+ ...this.averageRows.map((r) => [
+ r.code,
+ r.name,
+ r.group,
+ r.opening,
+ r.closing,
+ r.average,
+ r.peak,
+ r.lowest,
+ r.activeDays
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ null,
+ null,
+ this.avgTotalAverage,
+ null,
+ null,
+ null
+ ]
+ ]
+ };
+ case 'gl-mapping':
+ return {
+ headers: [
+ 'Return',
+ 'Return Line',
+ 'Mapped GL Accounts',
+ 'Ledger Value (Rs Thousands)'
+ ],
+ widths: [
+ 42,
+ 46,
+ 60,
+ 24
+ ],
+ rows: [
+ ...this.mappingRows.map((r) => [
+ r.returnLabel,
+ r.lineKey,
+ r.codeLabels,
+ r.value
+ ]),
+ ...(this.mappingRows.length ? [] : [[
+ '— no return lines mapped —',
+ '',
+ '',
+ null
+ ]]),
+ [
+ '',
+ '',
+ '',
+ null
+ ],
+ [
+ 'UNMAPPED GL ACCOUNTS',
+ '',
+ '',
+ null
+ ],
+ ...this.unmappedAccounts.map((a) => [
+ '',
+ a.code + ' — ' + a.name,
+ a.group,
+ null
+ ])
+ ]
+ };
+ case 'account-wise-balance':
+ return {
+ headers: [
+ 'GL Code',
+ 'Account',
+ 'Group',
+ ...this.branchColumns.map((c) => this.branchName(c)),
+ 'Total'
+ ],
+ widths: [
+ 12,
+ 40,
+ 24,
+ ...this.branchColumns.map(() => 18),
+ 20
+ ],
+ rows: [
+ ...this.accountWiseRows.map((r) => [
+ r.code,
+ r.name,
+ r.group,
+ ...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
+ r.total
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ ...this.branchColumns.map((c) => this.branchColumnTotal(c)),
+ this.accountWiseGrandTotal
+ ]
+ ]
+ };
+ case 'distribution-of-balance':
+ return {
+ headers: [
+ 'Side',
+ 'Group',
+ 'Accounts',
+ 'Amount',
+ 'Share %'
+ ],
+ widths: [
+ 16,
+ 40,
+ 14,
+ 22,
+ 14
+ ],
+ rows: [
+ ...this.liabilityDistribution.map((r) => [
+ 'Liability',
+ r.group,
+ r.accounts,
+ r.amount,
+ Number(r.share.toFixed(2))
+ ]),
+ [
+ 'Total Liabilities',
+ '',
+ null,
+ this.liabilityDistTotal,
+ 100
+ ],
+ ...this.assetDistribution.map((r) => [
+ 'Asset',
+ r.group,
+ r.accounts,
+ r.amount,
+ Number(r.share.toFixed(2))
+ ]),
+ [
+ 'Total Assets',
+ '',
+ null,
+ this.assetDistTotal,
+ 100
+ ]
+ ]
+ };
+ case 'client-wise-balance':
+ return {
+ headers: [
+ 'CIF',
+ 'Customer',
+ 'Branch',
+ 'Account No',
+ 'Product',
+ 'Side',
+ 'Balance'
+ ],
+ widths: [
+ 14,
+ 34,
+ 18,
+ 24,
+ 34,
+ 12,
+ 20
+ ],
+ rows: [
+ ...this.clientRows.map((r) => [
+ r.cif,
+ r.name,
+ this.branchName(r.branchCode),
+ r.accountNo,
+ r.product,
+ r.side,
+ r.balance
+ ]),
+ [
+ 'TOTAL DEPOSITS',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.clientDeposits
+ ],
+ [
+ 'TOTAL ADVANCES',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.clientAdvances
+ ]
+ ]
+ };
+ case 'banker-head':
+ return {
+ headers: [
+ 'Principal Head',
+ 'Accounts',
+ 'Opening',
+ 'Debit',
+ 'Credit',
+ 'Closing'
+ ],
+ widths: [
+ 34,
+ 12,
+ 20,
+ 20,
+ 20,
+ 20
+ ],
+ rows: [
+ ...this.bankerHeadRows.map((r) => [
+ r.head,
+ r.accounts,
+ r.opening,
+ r.debit,
+ r.credit,
+ r.closing
+ ]),
+ [
+ 'TOTAL',
+ null,
+ null,
+ this.headDebitTotal,
+ this.headCreditTotal,
+ null
+ ]
+ ]
+ };
+ case 'sundry-register':
+ return {
+ headers: [
+ 'Date',
+ 'Voucher No',
+ 'Branch',
+ 'GL Code',
+ 'Account',
+ 'Narration',
+ 'Amount',
+ 'Age (days)',
+ 'Ageing Bucket'
+ ],
+ widths: [
+ 14,
+ 20,
+ 18,
+ 12,
+ 32,
+ 42,
+ 18,
+ 12,
+ 18
+ ],
+ rows: [
+ ...this.sundryRows.map((r) => [
+ r.date,
+ r.voucherNo,
+ this.branchName(r.branchCode),
+ r.code,
+ r.name,
+ r.narration,
+ r.amount,
+ r.ageDays,
+ r.bucket
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.sundryTotal,
+ null,
+ ''
+ ]
+ ]
+ };
+ case 'voucher-register':
+ return {
+ headers: [
+ 'Date',
+ 'Voucher No',
+ 'Branch',
+ 'Narration',
+ 'Debit Account(s)',
+ 'Credit Account(s)',
+ 'Amount',
+ 'Legs'
+ ],
+ widths: [
+ 14,
+ 20,
+ 18,
+ 42,
+ 38,
+ 38,
+ 20,
+ 10
+ ],
+ rows: [
+ ...this.voucherRows.map((r) => [
+ r.date,
+ r.voucherNo,
+ this.branchName(r.branchCode),
+ r.narration,
+ r.debitAccounts,
+ r.creditAccounts,
+ r.amount,
+ r.legs
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.voucherTotal,
+ null
+ ]
]
};
case 'misc-expense-ledger':
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
'Narration',
'Amount'
],
- widths: [14, 20, 18, 12, 36, 46, 18],
+ widths: [
+ 14,
+ 20,
+ 18,
+ 12,
+ 36,
+ 46,
+ 18
+ ],
rows: [
...this.miscRows.map((r) => [
r.date,
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
r.narration,
r.amount
]),
- ['TOTAL', '', '', '', '', '', this.miscTotal]
+ [
+ 'TOTAL',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.miscTotal
+ ]
]
};
}
diff --git a/src/app/gl-reports/gl-reports.compute.ts b/src/app/gl-reports/gl-reports.compute.ts
index 705d7fcaf..e33f221a2 100644
--- a/src/app/gl-reports/gl-reports.compute.ts
+++ b/src/app/gl-reports/gl-reports.compute.ts
@@ -15,14 +15,7 @@
* postings and Angular re-evaluates template getters on every change detection.
*/
-import {
- GL_ACCOUNTS,
- GL_PERIOD,
- GlAccount,
- GlPosting,
- glAccount,
- glPostings
-} from './gl-reports-data';
+import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
export interface GlDateRange {
from: string;
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode;
}
+/**
+ * Both scans below walk the whole ~21k-posting ledger, so their results are cached
+ * per (date, branch). Angular re-evaluates template getters on every change-detection
+ * pass; without this a screen with a few hundred mapped lines would spend seconds per
+ * click rescanning the ledger and appear frozen.
+ */
+const balanceCache = new Map
+ Applies over
+
+
+
+
+
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||