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
@@ -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);
}
}