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:
@@ -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,
|
||||
|
||||
@@ -1299,27 +1299,27 @@
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Core & 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&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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user