diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7ec73cbd5..9d7c9aab5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 30f3faa1a..825abc657 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1299,27 +1299,27 @@
Core & Books
- - - - - - @@ -1328,19 +1328,19 @@
Bank & Clearing
- - - - @@ -1349,15 +1349,15 @@
Digital & Payments
- - - @@ -1366,11 +1366,11 @@
Instruments & Members
- - @@ -1379,11 +1379,11 @@
Compliance & Financials
- - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 79000306a..44823418c 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -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; diff --git a/src/app/reconciliation/reconciliation-data.ts b/src/app/reconciliation/reconciliation-data.ts new file mode 100644 index 000000000..3a8c8eaec --- /dev/null +++ b/src/app/reconciliation/reconciliation-data.ts @@ -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 = { + 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 = (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; +} diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html new file mode 100644 index 000000000..f6090a664 --- /dev/null +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.html @@ -0,0 +1,228 @@ + + +
+
+
+
+ RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }} + {{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }} +
+

{{ meta.label }}

+

+ {{ meta.sideALabel }} vs {{ meta.sideBLabel }} + @if (meta.extraLabel) { + vs {{ meta.extraLabel }} + } + — as on {{ asOnDate }}. +

+
+
+ @if (meta.scope === 'BRANCH') { + @if (isHeadOffice) { + + } @else { + + } + } @else { + + } + + +
+
+ + @if (!isRestricted) { +
+
+ {{ meta.sideALabel }} + ₹{{ totalA | number: '1.0-0' }} + {{ items.length }} items +
+
+ {{ meta.sideBLabel }} + ₹{{ totalB | number: '1.0-0' }} + {{ scopeName }} +
+
+ Absolute Difference + ₹{{ totalDiff | number: '1.0-0' }} + {{ matchPct | number: '1.1-1' }}% matched +
+
+ Open Items + {{ unmatchedCount + pendingCount }} + {{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved +
+
+ } + +
+ @for (g of groups; track g) { + + } +
+ +
+ @for (m of groupOf(activeGroup); track m.id) { + + } +
+ + @if (isRestricted) { +
+
+ +
Managed Centrally by Head Office
+

+ {{ 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. +

+
+
+ } @else { +
+ +
+ + + + + + @if (meta.scope === 'BRANCH') { + + } + + + + @if (meta.extraLabel) { + + } + + + + + + + @for (item of paged(items); track item.id) { + + + + @if (meta.scope === 'BRANCH') { + + } + + + + @if (meta.extraLabel) { + + } + + + + + } @empty { + + + + } + +
{{ meta.referenceLabel }}DescriptionBranchDate{{ meta.sideALabel }}{{ meta.sideBLabel }}{{ meta.extraLabel }}DifferenceStatusRemarks / Action
+ {{ item.reference }} + {{ item.description }}{{ item.branchName }}{{ item.date }}₹{{ item.sideAAmount | number: '1.0-0' }}₹{{ item.sideBAmount | number: '1.0-0' }}₹{{ item.extraAmount | number: '1.0-0' }} + ₹{{ diff(item) | number: '1.0-0' }} + + {{ item.status }} + + @if (isItemOpen(item)) { +
+ + +
+ } @else if (item.status === 'RESOLVED') { + {{ item.remarks }} + } +
+ No {{ meta.label }} items for this branch. +
+
+ @if (items.length > pageSize) { +
+ Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }} +
+ + Page {{ currentPage }} of {{ pageCount(items) }} + +
+
+ } +
+ } +
+ +
{{ toastMsg }}
diff --git a/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts new file mode 100644 index 000000000..683398761 --- /dev/null +++ b/src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts @@ -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 = {}; + + toastMsg = ''; + toastVisible = false; + private toastTimer: ReturnType | 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(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); + } +} diff --git a/src/app/reconciliation/reconciliation-routing.module.ts b/src/app/reconciliation/reconciliation-routing.module.ts new file mode 100644 index 000000000..148fd4127 --- /dev/null +++ b/src/app/reconciliation/reconciliation-routing.module.ts @@ -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 {} diff --git a/src/app/reconciliation/reconciliation-shared.scss b/src/app/reconciliation/reconciliation-shared.scss new file mode 100644 index 000000000..edc6a1fae --- /dev/null +++ b/src/app/reconciliation/reconciliation-shared.scss @@ -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; + } +} diff --git a/src/app/reconciliation/reconciliation.module.ts b/src/app/reconciliation/reconciliation.module.ts new file mode 100644 index 000000000..ccbdd08d3 --- /dev/null +++ b/src/app/reconciliation/reconciliation.module.ts @@ -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 {}