feat(reconciliation): build full 16-screen Reconciliation module

Wires every item in the Reconciliation mega-menu (Core & Books, Bank &
Clearing, Digital & Payments, Instruments & Members, Compliance &
Financials) to a working screen, mirroring the NPA module's pattern: one
route-param-driven component over a shared two-sided (Side A vs Side B)
matching model, with pagination and a "Mark as Reconciled" action on every
screen.

Access is split by real-world scope: activity that happens at a branch
(vouchers, cheques, digital transactions, share members, fixed assets) is
branch-wise with the same Head-Office/branch login behavior as NPA; activity
with no branch dimension in real banking (GL/P&L consolidation, treasury's
own bank accounts, statutory tax filing, vendor procurement, inter-branch
netting) is Head-Office-only, with branch logins shown a restricted notice
instead of the data.

Also adds the .menu-item-live "working" indicator to mat-menu-item buttons
(same convention as .gl-link--live) and applies it across the reconciliation
menu and the pre-existing DD/Pay Order Reconciliation link.
This commit is contained in:
2026-08-01 13:15:17 +05:30
parent 4261f9659e
commit c935e2a62a
9 changed files with 1386 additions and 17 deletions
+2
View File
@@ -67,6 +67,7 @@ import { FiuModule } from './fiu/fiu.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
SftModule,
InvestmentModule,
NpaModule,
ReconciliationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
+17 -17
View File
@@ -1299,27 +1299,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
</button>
@@ -1328,19 +1328,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
</button>
@@ -1349,15 +1349,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM)
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
</button>
@@ -1366,11 +1366,11 @@
<!-- Instruments & Members -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0" class="menu-item-live">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
</button>
@@ -1379,11 +1379,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
+20
View File
@@ -677,6 +677,26 @@ $gray-700: #334155;
opacity: 0.7;
}
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
// mat-menu-item's internal layout isn't a plain flex row we control directly.
[mat-menu-item].menu-item-live {
position: relative;
&::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
}
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
@@ -0,0 +1,703 @@
/**
* 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/.
*/
// Dummy data layer for the "Reconciliation" mega-menu — 16 screens across 5 groups (Core &
// Books / Bank & Clearing / Digital & Payments / Instruments & Members / Compliance &
// Financials). Every reconciliation type shares one generic two-sided model (Side A vs Side B,
// difference, match status) so the UI, KPIs and "Mark as Reconciled" action are all driven by
// the same pure functions below — no report can duplicate/contradict another's numbers.
//
// Branches are the SAME 8 used by the NPA module (Branch 1/Branch 2 + 6 named branches) so a
// real bank demo never shows two different branch lists in two different menus.
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES };
export type { BranchMaster };
export type ReconGroupId = 'core' | 'bank' | 'digital' | 'instruments' | 'compliance';
export type ReconScope = 'BRANCH' | 'HEAD_OFFICE';
export type ReconStatus = 'MATCHED' | 'UNMATCHED' | 'PENDING' | 'RESOLVED';
export const GROUP_LABELS: Record<ReconGroupId, string> = {
core: 'Core & Books',
bank: 'Bank & Clearing',
digital: 'Digital & Payments',
instruments: 'Instruments & Members',
compliance: 'Compliance & Financials'
};
export interface ReconTypeMeta {
id: string;
group: ReconGroupId;
label: string;
/**
* BRANCH — the underlying activity is booked at a specific branch (vouchers, cheques,
* customer digital transactions, share members, fixed assets), so Head Office sees a branch
* selector + consolidated view and a branch login is locked to its own branch — same pattern
* as the NPA module.
* HEAD_OFFICE — the activity has no branch dimension in real life (bank-wide P&L/GL control,
* treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) — only Head Office can open these; a branch login sees a restricted notice.
*/
scope: ReconScope;
sideALabel: string;
sideBLabel: string;
referenceLabel: string;
/** Only set for the one genuinely three-sided report (Three-way Digital Reconciliation). */
extraLabel?: string;
}
export const RECON_TYPES: ReconTypeMeta[] = [
{
id: 'voucher',
group: 'core',
label: 'Voucher Reconciliation',
scope: 'BRANCH',
sideALabel: 'System Voucher Amount',
sideBLabel: 'Physical / Scanned Voucher Amount',
referenceLabel: 'Voucher No'
},
{
id: 'sundry-dr-cr',
group: 'core',
label: 'Sundry Dr / Cr Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Balance',
sideBLabel: 'Party Ledger Balance',
referenceLabel: 'Party Code'
},
{
id: 'pnl-impact',
group: 'core',
label: 'P&L Impact Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'P&L as per GL',
sideBLabel: 'P&L as per MIS Schedule',
referenceLabel: 'Income / Expense Head'
},
{
id: 'gl-reconciliation',
group: 'core',
label: 'GL Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Total Debit',
sideBLabel: 'Total Credit',
referenceLabel: 'GL Code'
},
{
id: 'fixed-assets',
group: 'core',
label: 'Fixed Assets Reconciliation',
scope: 'BRANCH',
sideALabel: 'GL Asset Value (WDV)',
sideBLabel: 'Physical Verification Value',
referenceLabel: 'Asset Tag No'
},
{
id: 'inter-company',
group: 'core',
label: 'Inter-Company Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'HO Books (Inter-Branch A/c)',
sideBLabel: 'Branch Books (HO A/c)',
referenceLabel: 'Branch'
},
{
id: 'bank-reco',
group: 'bank',
label: 'Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Balance per Bank GL (Books)',
sideBLabel: 'Balance per Bank Statement',
referenceLabel: 'Bank Account'
},
{
id: 'api-bank-reco',
group: 'bank',
label: 'API Based Bank Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Balance',
sideBLabel: 'API Feed Balance',
referenceLabel: 'Bank Account (API)'
},
{
id: 'neft-rtgs',
group: 'bank',
label: 'NEFT / RTGS Reconciliation',
scope: 'BRANCH',
sideALabel: 'Amount per CBS',
sideBLabel: 'Amount per RBI Settlement File',
referenceLabel: 'UTR No'
},
{
id: 'clearing-reco',
group: 'bank',
label: 'Clearing Reconciliation',
scope: 'BRANCH',
sideALabel: 'Cheque Amount per CBS',
sideBLabel: 'Amount per Clearing House (CTS)',
referenceLabel: 'Cheque No'
},
{
id: 'udir',
group: 'digital',
label: 'UDIR (IMPS / UPI / ATM)',
scope: 'BRANCH',
sideALabel: 'Disputed Amount (Customer Claim)',
sideBLabel: 'NPCI UDIR Resolution Amount',
referenceLabel: 'UDIR Ticket No'
},
{
id: 'three-way-digital',
group: 'digital',
label: 'Three-way Digital Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Core Banking Record',
sideBLabel: 'Settlement Bank Record',
extraLabel: 'Switch / NPCI Record',
referenceLabel: 'Transaction Ref (RRN)'
},
{
id: 'nach-reco',
group: 'digital',
label: 'NACH Reconciliation',
scope: 'BRANCH',
sideALabel: 'Mandate Amount per CBS',
sideBLabel: 'Amount per NPCI Response File',
referenceLabel: 'UMRN'
},
{
id: 'share-member',
group: 'instruments',
label: 'Share Member Reconciliation',
scope: 'BRANCH',
sideALabel: 'Share GL Balance',
sideBLabel: 'Member Share Ledger Total',
referenceLabel: 'Folio No'
},
{
id: 'invoice-payment',
group: 'compliance',
label: 'Invoice Payment',
scope: 'HEAD_OFFICE',
sideALabel: 'Invoice Amount (Vendor Bill)',
sideBLabel: 'Amount Paid per GL',
referenceLabel: 'Invoice No'
},
{
id: 'tax-reco',
group: 'compliance',
label: 'Tax Reconciliation',
scope: 'HEAD_OFFICE',
sideALabel: 'Deducted / Collected per Books',
sideBLabel: 'Remitted to Government (Challan)',
referenceLabel: 'Challan No'
}
];
export const DEFAULT_RECON_ID = 'voucher';
export function reconMeta(id: string): ReconTypeMeta {
return RECON_TYPES.find((t) => t.id === id) || RECON_TYPES[0];
}
export interface ReconItem {
id: string;
reconTypeId: string;
branchCode: string;
branchName: string;
groupLabel: string;
reference: string;
description: string;
date: string;
sideAAmount: number;
sideBAmount: number;
extraAmount?: number;
status: ReconStatus;
remarks: string;
}
export function difference(item: ReconItem): number {
return +(item.sideAAmount - item.sideBAmount).toFixed(2);
}
export function isOpen(item: ReconItem): boolean {
return item.status === 'UNMATCHED' || item.status === 'PENDING';
}
// -------------------------------------------------------------------------
// Date helpers (same shape as npa-data.ts, kept local so this module has no
// runtime dependency beyond the shared BRANCHES list)
// -------------------------------------------------------------------------
function fmt(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
export function todayDDMMYYYY(): string {
return fmt(new Date());
}
// -------------------------------------------------------------------------
// Seeded RNG — deterministic dataset across reloads (own seed, independent of npa-data.ts)
// -------------------------------------------------------------------------
function mulberry32(seed: number): () => number {
return function random(): number {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(20260213);
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
const between = (min: number, max: number): number => min + rng() * (max - min);
const round = (n: number): number => Math.round(n);
const VENDOR_NAMES = [
'Om Stationery Suppliers',
'Shubham Printers',
'Shree Balaji AMC Services',
'Metro Security Services',
'Sai Computers & Networking',
'Deccan Furniture Works',
'Unity Housekeeping Services',
'Precision Fire & Safety',
'Konark Electricals',
'Vasant Stationery Mart'
];
const PARTY_NAMES = [
'Kiran Hardware',
'Vishal Electronics',
'Nilesh Electricals',
'Ramdas Plumbing Works',
'Anita Traders',
'Ganesh Enterprises',
'Om Textiles',
'Balaji Agro Traders',
'Sunil Auto Parts',
'Meera Fabricators'
];
const ASSET_NAMES = [
'Office Furniture Set',
'Desktop Computer (Branch Counter)',
'UPS 3 KVA',
'Currency Counting Machine',
'Air Conditioner 1.5 Ton',
'CCTV Camera System',
'Branch Generator 15 KVA',
'Server Rack Cabinet',
'Biometric Attendance Device',
'Two-Wheeler (Recovery Officer)'
];
const GL_HEADS = [
'Interest on Loans & Advances',
'Interest on Investments',
'Interest Paid on Deposits',
'Commission & Exchange',
'Locker Rent Income',
'Establishment Charges',
'Rent, Taxes & Lighting',
'Printing & Stationery',
'Depreciation on Fixed Assets',
'Bad Debts Written Off',
'Audit Fees',
'Sundry Income'
];
const BANK_ACCOUNTS = [
'SBI Current Account - 40012345678',
'HDFC Bank Current Account - 5020012345',
'ICICI Bank Current Account - 000305012345',
'Axis Bank Current Account - 91020012345',
'RBI Current Account (Banking Dept)'
];
interface GenOptions {
reconTypeId: string;
refPrefix: string;
refDigits: number;
descriptions: string[];
minAmt: number;
maxAmt: number;
unmatchedRate: number;
branchScoped: boolean;
countPerBranch?: number;
totalCount?: number;
today: Date;
}
function statusFor(unmatchedRate: number): ReconStatus {
const r = rng();
if (r < unmatchedRate * 0.6) {
return 'UNMATCHED';
}
if (r < unmatchedRate) {
return 'PENDING';
}
return 'MATCHED';
}
function amountsFor(status: ReconStatus, minAmt: number, maxAmt: number): { a: number; b: number } {
const a = round(between(minAmt, maxAmt));
if (status === 'MATCHED') {
return { a, b: a };
}
const gapPct = between(0.02, 0.35);
const sign = rng() < 0.5 ? 1 : -1;
const b = round(a * (1 - sign * gapPct));
return { a, b };
}
function generateGeneric(opts: GenOptions): ReconItem[] {
const items: ReconItem[] = [];
const meta = reconMeta(opts.reconTypeId);
const branchLoop = opts.branchScoped ? BRANCHES : [{ code: 'HO', name: 'Head Office' }];
const perBranch = opts.countPerBranch ?? opts.totalCount ?? 20;
branchLoop.forEach((branch, branchIdx) => {
const count = opts.branchScoped ? perBranch : (opts.totalCount ?? perBranch);
for (let i = 0; i < count; i++) {
const status = statusFor(opts.unmatchedRate);
const { a, b } = amountsFor(status, opts.minAmt, opts.maxAmt);
const daysAgo = round(between(1, 75));
const seq = String(branchIdx * 1000 + i + 1).padStart(opts.refDigits, '0');
items.push({
id: `${opts.reconTypeId}-${branch.code}-${i}`,
reconTypeId: opts.reconTypeId,
branchCode: branch.code,
branchName: branch.name,
groupLabel: GROUP_LABELS[meta.group],
reference: `${opts.refPrefix}${seq}`,
description: pick(opts.descriptions),
date: fmt(addDays(opts.today, -daysAgo)),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: status === 'MATCHED' ? '' : ''
});
}
});
return items;
}
function generateInterCompany(today: Date): ReconItem[] {
return BRANCHES.map((b): ReconItem => {
const status = statusFor(0.25);
const { a, b: bAmt } = amountsFor(status, 200000, 3500000);
return {
id: `inter-company-${b.code}`,
reconTypeId: 'inter-company',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.core,
reference: b.name,
description: `Inter-branch settlement account — ${b.name}`,
date: fmt(addDays(today, -round(between(1, 10)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : bAmt,
status,
remarks: ''
};
});
}
function generateBankReco(reconTypeId: string, today: Date, refPrefix: string): ReconItem[] {
const templates = [
'Cheque issued, not yet presented',
'Cheque deposited, not yet credited by bank',
'Bank charges debited, not yet booked in CBS',
'Interest credited by bank, not yet booked in CBS',
'Direct credit by remitter, not yet identified',
'Standing instruction debited by bank, pending booking',
'RTGS outward debited by bank, in-transit in CBS'
];
const items: ReconItem[] = [];
BANK_ACCOUNTS.forEach((acct, idx) => {
const lineCount = reconTypeId === 'api-bank-reco' ? 5 : 6;
for (let i = 0; i < lineCount; i++) {
const status = statusFor(0.55);
const { a, b } = amountsFor(status, 15000, 850000);
items.push({
id: `${reconTypeId}-${idx}-${i}`,
reconTypeId,
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.bank,
reference: `${refPrefix}${String(idx + 1).padStart(2, '0')}${String(i + 1).padStart(3, '0')}`,
description: `${acct} — ${pick(templates)}`,
date: fmt(addDays(today, -round(between(1, 30)))),
sideAAmount: a,
sideBAmount: status === 'MATCHED' ? a : b,
status,
remarks: ''
});
}
});
return items;
}
function generateThreeWayDigital(today: Date): ReconItem[] {
const channels = [
'IMPS',
'UPI',
'NEFT',
'RTGS',
'ATM (NFS)'
];
const items: ReconItem[] = [];
for (let i = 0; i < 45; i++) {
const status = statusFor(0.12);
const core = round(between(500, 250000));
const switchAmt = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.2)));
const settlement = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.15)));
items.push({
id: `three-way-digital-${i}`,
reconTypeId: 'three-way-digital',
branchCode: 'HO',
branchName: 'Head Office',
groupLabel: GROUP_LABELS.digital,
reference: `RRN${String(700000 + i)}`,
description: `${pick(channels)} transaction — Core vs Switch vs Settlement Bank`,
date: fmt(addDays(today, -round(between(1, 20)))),
sideAAmount: core,
sideBAmount: settlement,
extraAmount: switchAmt,
status,
remarks: ''
});
}
return items;
}
function generateAll(): ReconItem[] {
const today = new Date();
return [
...generateGeneric({
reconTypeId: 'voucher',
refPrefix: 'JV/26/',
refDigits: 5,
descriptions: [
'Cash deposit voucher',
'Cash withdrawal voucher',
'Journal entry — interest provision',
'Journal entry — expense booking',
'Transfer voucher — inter-GL',
'Debit voucher — charges recovery',
'Credit voucher — refund adjustment'
],
minAmt: 500,
maxAmt: 250000,
unmatchedRate: 0.08,
branchScoped: true,
countPerBranch: 15,
today
}),
...generateGeneric({
reconTypeId: 'sundry-dr-cr',
refPrefix: 'PTY-',
refDigits: 4,
descriptions: PARTY_NAMES,
minAmt: 5000,
maxAmt: 180000,
unmatchedRate: 0.18,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'pnl-impact',
refPrefix: 'GLH-',
refDigits: 3,
descriptions: GL_HEADS,
minAmt: 40000,
maxAmt: 4200000,
unmatchedRate: 0.15,
branchScoped: false,
totalCount: 24,
today
}),
...generateGeneric({
reconTypeId: 'gl-reconciliation',
refPrefix: 'GL-',
refDigits: 6,
descriptions: [
'Suspense account clearance check',
'Control account tally — deposits',
'Control account tally — advances',
'Inter-branch adjustment head',
'Sundry assets/liabilities head',
...GL_HEADS
],
minAmt: 20000,
maxAmt: 5200000,
unmatchedRate: 0.1,
branchScoped: false,
totalCount: 42,
today
}),
...generateGeneric({
reconTypeId: 'fixed-assets',
refPrefix: 'FA-',
refDigits: 4,
descriptions: ASSET_NAMES,
minAmt: 8000,
maxAmt: 650000,
unmatchedRate: 0.12,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateInterCompany(today),
...generateBankReco('bank-reco', today, 'BRS-'),
...generateBankReco('api-bank-reco', today, 'API-'),
...generateGeneric({
reconTypeId: 'neft-rtgs',
refPrefix: 'UTR',
refDigits: 12,
descriptions: [
'RTGS outward — beneficiary bank settlement',
'RTGS inward — remitting bank settlement',
'NEFT outward — beneficiary bank settlement',
'NEFT inward — remitting bank settlement'
],
minAmt: 25000,
maxAmt: 1800000,
unmatchedRate: 0.06,
branchScoped: true,
countPerBranch: 12,
today
}),
...generateGeneric({
reconTypeId: 'clearing-reco',
refPrefix: 'CHQ-',
refDigits: 6,
descriptions: [
'Outward clearing — cheque sent',
'Inward clearing — cheque received',
'CTS grid settlement adjustment',
'Cheque return — insufficient funds'
],
minAmt: 10000,
maxAmt: 620000,
unmatchedRate: 0.09,
branchScoped: true,
countPerBranch: 10,
today
}),
...generateGeneric({
reconTypeId: 'udir',
refPrefix: 'UDIR-',
refDigits: 5,
descriptions: [
'IMPS failed but debited — customer dispute',
'UPI transaction disputed — merchant not credited',
'ATM cash-not-dispensed but account debited',
'UPI duplicate debit dispute'
],
minAmt: 500,
maxAmt: 45000,
unmatchedRate: 0.35,
branchScoped: true,
countPerBranch: 4,
today
}),
...generateThreeWayDigital(today),
...generateGeneric({
reconTypeId: 'nach-reco',
refPrefix: 'UMRN',
refDigits: 10,
descriptions: [
'NACH debit — loan EMI collection',
'NACH debit — gold loan EMI collection',
'NACH credit — salary/DBT disbursement',
'NACH mandate return — insufficient funds'
],
minAmt: 1500,
maxAmt: 85000,
unmatchedRate: 0.14,
branchScoped: true,
countPerBranch: 8,
today
}),
...generateGeneric({
reconTypeId: 'share-member',
refPrefix: 'FOL-',
refDigits: 5,
descriptions: [
'Regular member — share balance',
'Nominal member — share balance',
'Share transfer on account closure',
'Share application pending allotment'
],
minAmt: 500,
maxAmt: 25000,
unmatchedRate: 0.07,
branchScoped: true,
countPerBranch: 9,
today
}),
...generateGeneric({
reconTypeId: 'invoice-payment',
refPrefix: 'INV-',
refDigits: 5,
descriptions: VENDOR_NAMES,
minAmt: 3000,
maxAmt: 380000,
unmatchedRate: 0.13,
branchScoped: false,
totalCount: 30,
today
}),
...generateGeneric({
reconTypeId: 'tax-reco',
refPrefix: 'CHLN-',
refDigits: 4,
descriptions: [
'TDS on FD/RD interest payouts',
'TDS on salary payments',
'TDS on contractor/vendor payments',
'GST on service charges collected',
'GST on locker rent collected',
'TCS on cash transactions'
],
minAmt: 8000,
maxAmt: 620000,
unmatchedRate: 0.11,
branchScoped: false,
totalCount: 18,
today
})
];
}
export const RECON_ITEMS: ReconItem[] = generateAll();
// -------------------------------------------------------------------------
// KPI helpers — always computed from the item list handed in, so a filtered
// (branch-scoped) view and the full book can never disagree on a total.
// -------------------------------------------------------------------------
export function totalSideA(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideAAmount, 0);
}
export function totalSideB(items: ReconItem[]): number {
return items.reduce((s, i) => s + i.sideBAmount, 0);
}
export function totalDifference(items: ReconItem[]): number {
return items.reduce((s, i) => s + Math.abs(difference(i)), 0);
}
export function countByStatus(items: ReconItem[], status: ReconStatus): number {
return items.filter((i) => i.status === status).length;
}
export function matchRate(items: ReconItem[]): number {
return items.length ? (countByStatus(items, 'MATCHED') / items.length) * 100 : 100;
}
@@ -0,0 +1,228 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">
RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }}
<span class="recon-scope-chip">{{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }}</span>
</div>
<h1 class="pg-title">{{ meta.label }}</h1>
<p class="pg-sub">
{{ meta.sideALabel }} vs {{ meta.sideBLabel }}
@if (meta.extraLabel) {
vs {{ meta.extraLabel }}
}
— as on {{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (meta.scope === 'BRANCH') {
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">-- All Branches --</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
} @else {
<label class="pg-field">
<span>Branch</span>
<span class="pg-chip">{{ scopeName }}</span>
</label>
}
} @else {
<label class="pg-field">
<span>Scope</span>
<span class="pg-chip">Head Office (Whole Bank)</span>
</label>
}
<label class="pg-field">
<span>As On</span>
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
</div>
</header>
@if (!isRestricted) {
<div class="inv-kpis">
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideALabel }}</span>
<span class="inv-kpi__value">₹{{ totalA | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ items.length }} items</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">{{ meta.sideBLabel }}</span>
<span class="inv-kpi__value">₹{{ totalB | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ scopeName }}</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Absolute Difference</span>
<span class="inv-kpi__value" [class.npa-warn]="totalDiff > 0">₹{{ totalDiff | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">{{ matchPct | number: '1.1-1' }}% matched</span>
</div>
<div class="inv-kpi">
<span class="inv-kpi__label">Open Items</span>
<span class="inv-kpi__value npa-warn">{{ unmatchedCount + pendingCount }}</span>
<span class="inv-kpi__sub"
>{{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved</span
>
</div>
</div>
}
<div class="cctr-tabs">
@for (g of groups; track g) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeGroup === g"
(click)="goToReport(groupOf(g)[0].id)"
>
{{ groupLabels[g] }}
</button>
}
</div>
<div class="cctr-tabs">
@for (m of groupOf(activeGroup); track m.id) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeReport === m.id"
(click)="goToReport(m.id)"
>
{{ m.label }}
</button>
}
</div>
@if (isRestricted) {
<div class="pg-card">
<div class="recon-restricted">
<fa-icon icon="university" class="recon-restricted__icon"></fa-icon>
<div class="recon-restricted__title">Managed Centrally by Head Office</div>
<p class="recon-restricted__body">
{{ meta.label }} has no branch-level dimension in real-world banking — it is reconciled centrally by the Head
Office Accounts/Treasury team. Your login ({{ currentOfficeName }}) does not have access to this screen.
Contact Head Office if you need a figure from this reconciliation.
</p>
</div>
</div>
} @else {
<div class="pg-card">
<div class="pos-section-label">{{ meta.label }} — {{ scopeName }}</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>{{ meta.referenceLabel }}</th>
<th>Description</th>
@if (meta.scope === 'BRANCH') {
<th>Branch</th>
}
<th>Date</th>
<th class="num">{{ meta.sideALabel }}</th>
<th class="num">{{ meta.sideBLabel }}</th>
@if (meta.extraLabel) {
<th class="num">{{ meta.extraLabel }}</th>
}
<th class="num">Difference</th>
<th>Status</th>
<th>Remarks / Action</th>
</tr>
</thead>
<tbody>
@for (item of paged(items); track item.id) {
<tr>
<td>
<span class="pg-chip">{{ item.reference }}</span>
</td>
<td>{{ item.description }}</td>
@if (meta.scope === 'BRANCH') {
<td>{{ item.branchName }}</td>
}
<td>{{ item.date }}</td>
<td class="num">₹{{ item.sideAAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ item.sideBAmount | number: '1.0-0' }}</td>
@if (meta.extraLabel) {
<td class="num">₹{{ item.extraAmount | number: '1.0-0' }}</td>
}
<td class="num" [class.col-out]="diff(item) !== 0" [class.col-in]="diff(item) === 0">
₹{{ diff(item) | number: '1.0-0' }}
</td>
<td>
<span [class]="badgeClass(item.status)">{{ item.status }}</span>
</td>
<td>
@if (isItemOpen(item)) {
<div class="recon-action-row">
<input
type="text"
class="recon-remark-input"
[(ngModel)]="remarksDraft[item.id]"
placeholder="Remarks (optional)"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="markReconciled(item)">
Mark as Reconciled
</button>
</div>
} @else if (item.status === 'RESOLVED') {
<span class="pg-sub">{{ item.remarks }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td
[attr.colspan]="meta.scope === 'BRANCH' ? (meta.extraLabel ? 10 : 9) : meta.extraLabel ? 9 : 8"
class="pg-empty"
>
No {{ meta.label }} items for this branch.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (items.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, items)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(items) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(items)"
(click)="goToPage(currentPage + 1, items)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -0,0 +1,246 @@
/**
* 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, inject, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
RECON_TYPES,
RECON_ITEMS,
BRANCHES,
GROUP_LABELS,
DEFAULT_RECON_ID,
ReconGroupId,
ReconTypeMeta,
ReconItem,
ReconStatus,
reconMeta,
difference,
isOpen,
totalSideA,
totalSideB,
totalDifference,
countByStatus,
matchRate,
todayDDMMYYYY
} from '../reconciliation-data';
/**
* The "Reconciliation" mega-menu — 16 screens across 5 groups (Core & Books / Bank & Clearing /
* Digital & Payments / Instruments & Members / Compliance & Financials) — collapsed into one
* route-param-driven component, mirroring the NPA module's pattern. Every screen shares the same
* generic two-sided (Side A vs Side B) reconciliation model in reconciliation-data.ts, and the
* same Head-Office-vs-branch access split used by NPA and Cash/Clearing/Transfer: activity that
* genuinely happens at a branch (vouchers, cheques, digital transactions, share members, fixed
* assets) is branch-scoped; activity with no branch dimension in real life (bank-wide P&L/GL
* control, treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
* consolidation) is Head-Office-only.
*/
@Component({
selector: 'mifosx-reconciliation-report',
templateUrl: './reconciliation-report.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../reconciliation-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class ReconciliationReportComponent implements OnInit, OnDestroy {
readonly reportMeta = RECON_TYPES;
readonly groupLabels = GROUP_LABELS;
readonly branches = BRANCHES;
readonly groups: ReconGroupId[] = [
'core',
'bank',
'digital',
'instruments',
'compliance'
];
asOnDate = todayDDMMYYYY();
activeGroup: ReconGroupId = 'core';
activeReport = DEFAULT_RECON_ID;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
readonly pageSize = 20;
currentPage = 1;
remarksDraft: Record<string, string> = {};
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
private sub?: Subscription;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
ngOnInit(): void {
this.sub = this.route.paramMap.subscribe((params) => {
const id = params.get('reportId') || DEFAULT_RECON_ID;
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[0];
this.activeReport = meta.id;
this.activeGroup = meta.group;
this.currentPage = 1;
});
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goToReport(reportId: string): void {
this.router.navigate([
'/reconciliation',
reportId
]);
}
groupOf(group: ReconGroupId): ReconTypeMeta[] {
return this.reportMeta.filter((m) => m.group === group);
}
setBranch(code: string): void {
this.branchFilter = code;
this.currentPage = 1;
}
get meta(): ReconTypeMeta {
return reconMeta(this.activeReport);
}
/** True when a branch-level login has opened a Head-Office-only reconciliation type. */
get isRestricted(): boolean {
return this.meta.scope === 'HEAD_OFFICE' && !this.isHeadOffice;
}
get scopeName(): string {
if (this.meta.scope === 'HEAD_OFFICE') {
return 'Head Office';
}
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches';
}
get items(): ReconItem[] {
const all = RECON_ITEMS.filter((i) => i.reconTypeId === this.activeReport);
if (this.meta.scope !== 'BRANCH' || !this.branchFilter) {
return all;
}
return all.filter((i) => i.branchCode === this.branchFilter);
}
diff(item: ReconItem): number {
return difference(item);
}
isItemOpen(item: ReconItem): boolean {
return isOpen(item);
}
badgeClass(status: ReconStatus): string {
return `recon-badge recon-badge--${status.toLowerCase()}`;
}
// -------------------- KPIs (always over the full `items` list, never the paged slice) --------------------
get totalA(): number {
return totalSideA(this.items);
}
get totalB(): number {
return totalSideB(this.items);
}
get totalDiff(): number {
return totalDifference(this.items);
}
get matchedCount(): number {
return countByStatus(this.items, 'MATCHED');
}
get unmatchedCount(): number {
return countByStatus(this.items, 'UNMATCHED');
}
get pendingCount(): number {
return countByStatus(this.items, 'PENDING');
}
get resolvedCount(): number {
return countByStatus(this.items, 'RESOLVED');
}
get matchPct(): number {
return matchRate(this.items);
}
// -------------------- Pagination --------------------
paged<T>(list: T[]): T[] {
const start = (this.currentPage - 1) * this.pageSize;
return list.slice(start, start + this.pageSize);
}
pageCount(list: unknown[]): number {
return Math.max(1, Math.ceil(list.length / this.pageSize));
}
rangeStart(list: unknown[]): number {
return list.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
}
rangeEnd(list: unknown[]): number {
return Math.min(this.currentPage * this.pageSize, list.length);
}
goToPage(page: number, list: unknown[]): void {
this.currentPage = Math.min(Math.max(1, page), this.pageCount(list));
}
// -------------------- Actions --------------------
markReconciled(item: ReconItem): void {
const remark = (this.remarksDraft[item.id] || '').trim();
item.status = 'RESOLVED';
item.remarks = remark || 'Reconciled — difference verified and squared off.';
delete this.remarksDraft[item.id];
this.toast(`${this.meta.referenceLabel} ${item.reference} marked as reconciled.`);
}
generate(): void {
this.currentPage = 1;
this.toast(`${this.meta.label} refreshed as on ${this.asOnDate}.`);
}
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,41 @@
/**
* 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';
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
import { DEFAULT_RECON_ID } from './reconciliation-data';
const routes: Routes = [
Route.withShell([
{
path: 'reconciliation',
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' },
children: [
{
path: '',
redirectTo: DEFAULT_RECON_ID,
pathMatch: 'full'
},
{
path: ':reportId',
component: ReconciliationReportComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReconciliationRoutingModule {}
@@ -0,0 +1,107 @@
/**
* 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/.
*/
// Reconciliation-status colouring layered on top of ../cashier/cashier-shared.scss (pg-*,
// cctr-*), ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast) and
// ../npa/npa-shared.scss (pg-pagination) — reused as-is since they're generic primitives.
.recon-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
&--matched {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--unmatched {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--pending {
background: #fefce8;
color: #a16207;
border: 1px solid #fde68a;
}
&--resolved {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
.recon-scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.recon-action-row {
display: flex;
align-items: center;
gap: 8px;
}
.recon-remark-input {
height: 32px;
min-width: 160px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
color: #0d1b2a;
background: #fff;
}
.recon-restricted {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 56px 24px;
text-align: center;
color: #64748b;
&__icon {
font-size: 28px;
color: #94a3b8;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #334155;
}
&__body {
max-width: 440px;
font-size: 13px;
line-height: 1.5;
}
}
@@ -0,0 +1,22 @@
/**
* 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 { ReconciliationRoutingModule } from './reconciliation-routing.module';
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
@NgModule({
imports: [
SharedModule,
ReconciliationRoutingModule,
ReconciliationReportComponent
]
})
export class ReconciliationModule {}