feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position

- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card,
  denomination-wise validation against the master and real-time impact preview.
- Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and
  denomination holdings shown, blocked until both are allocated cash.
- Reconciliation, exchange and inter-cashier operate on the logged-in cashier only.
- New Branch Cash Position screen (vault + cashier drawers, denomination-wise).
- Customer search dialog shows the KYC PAN.
- Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row.
- Reverse actions removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 18:41:24 +05:30
co-authored by Claude Sonnet 5.5
parent 211027b9d8
commit 915deddb74
19 changed files with 2601 additions and 540 deletions
@@ -8,9 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
@@ -31,6 +29,8 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,7 +52,10 @@ interface DetailView {
loading: boolean;
}
/** Unified history of all cash movements with reversal and detail view support. */
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -63,16 +66,13 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
}
typeLabel(type: string): string {