/** * 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 { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatInputModule } from '@angular/material/input'; import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; interface Denom { id: number; value: number; kind: 'note' | 'coin'; label: string; } /** Denomination-wise cash transfer between two parties (e.g. Vault -> Cashier). */ @Component({ selector: 'mifosx-cash-transfer', templateUrl: './cash-transfer.component.html', styleUrls: [ '../cashier-shared.scss', './cash-transfer.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatSnackBarModule, MatFormFieldModule, MatSelectModule, MatInputModule ] }) export class CashTransferComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); notes: Denom[] = []; coins: Denom[] = []; counts: Record = {}; transferDate = ''; fromParty = ''; toParty = ''; note = ''; saving = false; /** Tellers — the "From" options (cash point / head). */ tellers: { id: number; name: string }[] = []; /** Cashiers of the currently selected From-teller — the "To" options. */ cashiers: { id: number; label: string }[] = []; fromTellerId: number | null = null; toCashierId: number | null = null; loadingCashiers = false; /** All denominations (notes then coins) — used for total and the submit payload. */ get denoms(): Denom[] { return [ ...this.notes, ...this.coins ]; } /** What the selected vault currently holds, denomination-wise — fetched so a clerk can see * before counting whether the vault even has enough of a given note, instead of finding out * only after the backend rejects the transfer. */ vaultAvailable: Record = {}; vaultTotalAvailable = 0; vaultHasBreakdown = true; loadingVault = false; ngOnInit(): void { const d = new Date(); this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ next: (list) => { const rows: Denom[] = (list || []).map((x) => ({ id: x.id, value: x.denomination, kind: x.kind, label: '₹' + x.denomination })); this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value); this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value); }, error: () => { this.notes = []; this.coins = []; } }); this.service .getTellers() .pipe(catchError(() => of([] as any[]))) .subscribe((list) => { this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); if (this.tellers.length === 1) { this.onTellerSelect(this.tellers[0].id); } }); } /** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown, and the * vault's own current denomination-wise stock so it can be shown against every count field. */ onTellerSelect(tellerId: number): void { this.fromTellerId = tellerId; const teller = this.tellers.find((t) => t.id === tellerId); this.fromParty = teller ? teller.name : ''; this.toParty = ''; this.toCashierId = null; this.cashiers = []; this.loadVaultAvailability(); if (!tellerId) { return; } this.loadingCashiers = true; this.service .getCashiersForTeller(tellerId) .pipe(catchError(() => of({ cashiers: [] }))) .subscribe((r: any) => { this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.loadingCashiers = false; if (this.cashiers.length === 1) { this.onCashierSelect(this.cashiers[0].id); } }); } /** Refetched whenever the vault or the transfer date changes — the vault's stock is only ever * meaningful as of a specific business date. */ loadVaultAvailability(): void { this.vaultAvailable = {}; this.vaultTotalAvailable = 0; this.vaultHasBreakdown = true; if (!this.fromTellerId) return; this.loadingVault = true; this.service .vaultPosition(this.fromTellerId, this.transferDate) .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.loadingVault = false; this.vaultHasBreakdown = !!res?.hasBreakdown; this.vaultTotalAvailable = Number(res?.totalAmount ?? 0); const map: Record = {}; ((res?.denominations || []) as any[]).forEach((d: any) => (map[d.denominationId] = d.count)); this.vaultAvailable = map; }); } onDateChange(value: string): void { this.transferDate = value; this.loadVaultAvailability(); } /** How many of this denomination the vault currently holds — 0, not undefined, when the vault's * breakdown is known but simply doesn't list this denomination (i.e. it truly has none left). */ availableFor(d: Denom): number { return this.vaultAvailable[d.id] ?? 0; } /** Whether the count entered for this denomination is more than the vault is known to hold — * only flagged once the vault's breakdown has actually loaded, so an empty in-flight state * doesn't flash every row as over-limit. */ exceedsAvailable(d: Denom): boolean { return this.vaultHasBreakdown && this.countFor(d) > this.availableFor(d); } /** When a To-cashier is chosen, capture its id and name. */ onCashierSelect(cashierId: number): void { this.toCashierId = cashierId; const c = this.cashiers.find((x) => x.id === cashierId); this.toParty = c ? c.label : ''; } countFor(d: Denom): number { return this.counts[d.id] || 0; } subtotal(d: Denom): number { return this.countFor(d) * d.value; } onChange(d: Denom, raw: any): void { this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); } bump(d: Denom, delta: number): void { this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); } get total(): number { return this.denoms.reduce((s, d) => s + this.subtotal(d), 0); } /** True once any counted denomination is over what the vault holds — checked client-side purely * so the clerk sees it before submitting; the backend enforces this too regardless. */ get anyDenomExceedsAvailable(): boolean { return this.denoms.some((d) => this.exceedsAvailable(d)); } get exceedsVaultTotal(): boolean { return this.vaultHasBreakdown && this.total > this.vaultTotalAvailable && this.total > 0; } get valid(): boolean { return ( this.total > 0 && !!this.fromTellerId && !!this.toCashierId && !this.anyDenomExceedsAvailable && !this.exceedsVaultTotal ); } clear(): void { this.counts = {}; } submit(): void { if (!this.valid || this.saving) { return; } this.saving = true; const payload = { transferDate: this.transferDate, fromTellerId: this.fromTellerId, toCashierId: this.toCashierId, fromParty: this.fromParty.trim(), toParty: this.toParty.trim(), note: this.note || null, denominations: this.denoms .filter((d) => this.countFor(d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) })) }; this.service.createCashTransfer(payload).subscribe({ next: (res: any) => { if (res?.glWarning) { this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', { duration: 8000, panelClass: ['snack-warn'] }); } else { this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 }); } this.clear(); this.note = ''; this.fromParty = ''; this.toParty = ''; this.fromTellerId = null; this.toCashierId = null; this.cashiers = []; this.saving = false; }, error: () => { this.saving = false; } }); } }