- 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>
424 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|