Files
cbsnexora/src/app/cashier/cash-history/cash-history.component.ts
T
niteshandClaude Sonnet 5.5 915deddb74 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>
2026-10-13 18:41:24 +05:30

424 lines
14 KiB
TypeScript

/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface TxnRow {
id: number;
date: string;
fromParty: string;
toParty: string;
fromCashierId?: number;
amount: number;
note: string;
isReversed: boolean;
performedByUsername: string;
createdAt: string;
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
interface DenomDetail {
denominationId: number | null;
denominationValue: number;
kind: 'note' | 'coin';
morningCount: number;
count: number;
totalAmount: number;
}
interface DetailView {
row: TxnRow;
denominations: DenomDetail[];
receivedDenominations: DenomDetail[];
givenDenominations: DenomDetail[];
morningTotal: number;
loading: boolean;
}
/**
* 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',
styleUrls: [
'../cashier-shared.scss',
'./cash-history.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
interCashier: TxnRow[] = [];
exchanges: TxnRow[] = [];
loading = false;
// Pagination — per tab
pageSize = 20;
pageSizeOptions = [
10,
20,
50,
100
];
transfersTotal = 0;
transfersOffset = 0;
settlementsTotal = 0;
settlementsOffset = 0;
interTotal = 0;
interOffset = 0;
exchangesTotal = 0;
exchangesOffset = 0;
private denomKindMap = new Map<number, 'note' | 'coin'>();
detail: DetailView | null = null;
ngOnInit(): void {
this.service
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
(list || []).forEach((d: any) => this.denomKindMap.set(d.id, d.kind === 'coin' ? 'coin' : 'note'));
});
this.load();
}
onPageChange(tab: 'transfers' | 'settlements' | 'inter' | 'exchanges', event: PageEvent): void {
this.pageSize = event.pageSize;
if (tab === 'transfers') {
this.transfersOffset = event.pageIndex * event.pageSize;
this.loadTransfers();
}
if (tab === 'settlements') {
this.settlementsOffset = event.pageIndex * event.pageSize;
this.loadSettlements();
}
if (tab === 'inter') {
this.interOffset = event.pageIndex * event.pageSize;
this.loadInter();
}
if (tab === 'exchanges') {
this.exchangesOffset = event.pageIndex * event.pageSize;
this.loadExchanges();
}
}
load(): void {
this.transfersOffset = 0;
this.settlementsOffset = 0;
this.interOffset = 0;
this.exchangesOffset = 0;
this.loading = true;
forkJoin({
transfers: this.service
.getCashTransfers(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
settlements: this.service
.getCashSettlements(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
inter: this.service
.getInterCashierTransfers(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))),
exchanges: this.service
.getCashExchanges(0, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
}).subscribe(({ transfers, settlements, inter, exchanges }) => {
this.transfersTotal = transfers.totalFilteredRecords ?? 0;
this.settlementsTotal = settlements.totalFilteredRecords ?? 0;
this.interTotal = inter.totalFilteredRecords ?? 0;
this.exchangesTotal = exchanges.totalFilteredRecords ?? 0;
const transferItems = transfers.pageItems ?? [];
const settlementItems = settlements.pageItems ?? [];
const interItems = inter.pageItems ?? [];
const exchangeItems = exchanges.pageItems ?? [];
this.transfers = (transferItems as any[]).map((t) => ({
id: t.id,
date: t.transferDate,
fromParty: t.fromParty,
toParty: t.toParty,
amount: t.transferAmount,
note: t.note,
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
reversedBy: t.reversedBy ?? null,
reversedAt: t.reversedAt ?? null,
reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
this.settlements = (settlementItems as any[]).map((s) => ({
id: s.id,
date: s.settlementDate,
fromParty: s.fromParty,
fromCashierId: s.fromCashierId ?? undefined,
toParty: s.toParty,
amount: s.settlementAmount,
note: s.note,
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
reversedBy: s.reversedBy ?? null,
reversedAt: s.reversedAt ?? null,
reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
this.interCashier = (interItems as any[]).map((i) => ({
id: i.id,
date: i.transferDate,
fromParty: i.fromParty,
toParty: i.toParty,
amount: i.transferAmount,
note: i.note,
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
reversedBy: i.reversedBy ?? null,
reversedAt: i.reversedAt ?? null,
reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
isReversed: e.isReversed ?? false,
performedByUsername: e.performedByUsername,
createdAt: e.createdAt,
reversedBy: e.reversedBy ?? null,
reversedAt: e.reversedAt ?? null,
reverseReason: e.reverseReason ?? null,
type: 'exchange'
}));
this.loading = false;
});
}
private loadTransfers(): void {
this.service
.getCashTransfers(this.transfersOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.transfersTotal = res.totalFilteredRecords ?? 0;
this.transfers = (res.pageItems ?? []).map((t: any) => ({
id: t.id,
date: t.transferDate,
fromParty: t.fromParty,
toParty: t.toParty,
amount: t.transferAmount,
note: t.note,
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
reversedBy: t.reversedBy ?? null,
reversedAt: t.reversedAt ?? null,
reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
});
}
private loadSettlements(): void {
this.service
.getCashSettlements(this.settlementsOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.settlementsTotal = res.totalFilteredRecords ?? 0;
this.settlements = (res.pageItems ?? []).map((s: any) => ({
id: s.id,
date: s.settlementDate,
fromParty: s.fromParty,
fromCashierId: s.fromCashierId ?? undefined,
toParty: s.toParty,
amount: s.settlementAmount,
note: s.note,
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
reversedBy: s.reversedBy ?? null,
reversedAt: s.reversedAt ?? null,
reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
});
}
private loadInter(): void {
this.service
.getInterCashierTransfers(this.interOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.interTotal = res.totalFilteredRecords ?? 0;
this.interCashier = (res.pageItems ?? []).map((i: any) => ({
id: i.id,
date: i.transferDate,
fromParty: i.fromParty,
toParty: i.toParty,
amount: i.transferAmount,
note: i.note,
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
reversedBy: i.reversedBy ?? null,
reversedAt: i.reversedAt ?? null,
reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
});
}
private loadExchanges(): void {
this.service
.getCashExchanges(this.exchangesOffset, this.pageSize)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.exchangesTotal = res.totalFilteredRecords ?? 0;
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
isReversed: e.isReversed ?? false,
performedByUsername: e.performedByUsername,
createdAt: e.createdAt,
reversedBy: e.reversedBy ?? null,
reversedAt: e.reversedAt ?? null,
reverseReason: e.reverseReason ?? null,
type: 'exchange'
}));
});
}
viewDetail(row: TxnRow): void {
this.detail = {
row,
denominations: [],
receivedDenominations: [],
givenDenominations: [],
morningTotal: 0,
loading: true
};
if (row.type === 'settlement' && row.fromCashierId) {
forkJoin({
detail: this.service.getCashSettlementById(row.id).pipe(catchError(() => of(null))),
morning: this.service.getCashierMorningBalance(row.fromCashierId, row.date).pipe(catchError(() => of(null)))
}).subscribe(({ detail, morning }) => {
if (!this.detail) return;
const morningMap = new Map<number, number>();
((morning?.denominations || []) as any[]).forEach((d: any) =>
morningMap.set(d.denominationId, d.morningCount ?? 0)
);
this.detail.morningTotal = Number(morning?.totalMorning ?? 0);
this.detail.denominations = ((detail?.details || []) as any[]).map((d: any) => ({
denominationId: d.denominationId ?? null,
denominationValue: d.denominationValue,
kind: this.denomKindMap.get(d.denominationId) ?? (d.denominationValue <= 5 ? 'coin' : 'note'),
morningCount: morningMap.get(d.denominationId) ?? 0,
count: d.count,
totalAmount: d.totalAmount
}));
this.detail.loading = false;
});
return;
}
let obs$;
if (row.type === 'transfer') obs$ = this.service.getCashTransferById(row.id);
else if (row.type === 'settlement') obs$ = this.service.getCashSettlementById(row.id);
else if (row.type === 'exchange') obs$ = this.service.getCashExchange(row.id);
else obs$ = this.service.getInterCashierById(row.id);
obs$.pipe(catchError(() => of(null))).subscribe((data: any) => {
if (this.detail) {
const mapDenom = (d: any): DenomDetail => ({
denominationId: d.denominationId ?? null,
denominationValue: d.denominationValue,
kind: this.denomKindMap.get(d.denominationId) ?? (d.denominationValue <= 5 ? 'coin' : 'note'),
morningCount: 0,
count: d.count,
totalAmount: d.totalAmount
});
if (row.type === 'exchange') {
const all: any[] = data?.details || [];
this.detail.receivedDenominations = all.filter((d: any) => d.direction === 'RECEIVED').map(mapDenom);
this.detail.givenDenominations = all.filter((d: any) => d.direction === 'GIVEN').map(mapDenom);
this.detail.denominations = this.detail.receivedDenominations;
} else {
this.detail.denominations = (data?.details || []).map(mapDenom);
}
this.detail.loading = false;
}
});
}
closeDetail(): void {
this.detail = null;
}
/** 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 {
if (type === 'transfer') return 'Cash Transfer';
if (type === 'settlement') return 'Cash Settlement';
if (type === 'exchange') return 'Cash Exchange';
return 'Inter-Cashier Transfer';
}
notes(denoms: DenomDetail[]): DenomDetail[] {
return denoms.filter((d) => d.kind === 'note').sort((a, b) => b.denominationValue - a.denominationValue);
}
coins(denoms: DenomDetail[]): DenomDetail[] {
return denoms.filter((d) => d.kind === 'coin').sort((a, b) => b.denominationValue - a.denominationValue);
}
subtotal(denoms: DenomDetail[]): number {
return denoms.reduce((s, d) => s + Number(d.totalAmount), 0);
}
totalCount(denoms: DenomDetail[]): number {
return denoms.reduce((s, d) => s + d.count, 0);
}
}