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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user