/** * 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(); 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(); ((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); } }