- 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>
300 lines
9.6 KiB
TypeScript
300 lines
9.6 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 { 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';
|
|
import { businessToday } from 'app/core/utils/business-today';
|
|
|
|
interface Denom {
|
|
id: number;
|
|
value: number;
|
|
kind: 'note' | 'coin';
|
|
label: string;
|
|
}
|
|
interface Cashier {
|
|
id: number;
|
|
label: string;
|
|
}
|
|
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
|
|
interface CashierSummary {
|
|
cashierId: number;
|
|
cashierName: string;
|
|
tellerName: string;
|
|
officeName: string;
|
|
cashAllocated: boolean;
|
|
allocatedAmount: number;
|
|
totalAmount: number;
|
|
/** Count held per denomination id. */
|
|
held: Record<number, number>;
|
|
}
|
|
|
|
/** Move cash denomination-wise between two cashiers under the same teller. */
|
|
@Component({
|
|
selector: 'mifosx-inter-cashier-transfer',
|
|
templateUrl: './inter-cashier-transfer.component.html',
|
|
styleUrls: [
|
|
'../cashier-shared.scss',
|
|
'./inter-cashier-transfer.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatIconModule,
|
|
MatSnackBarModule,
|
|
MatFormFieldModule,
|
|
MatSelectModule,
|
|
MatInputModule
|
|
]
|
|
})
|
|
export class InterCashierTransferComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private snackBar = inject(MatSnackBar);
|
|
|
|
notes: Denom[] = [];
|
|
coins: Denom[] = [];
|
|
counts: Record<number, number> = {};
|
|
transferDate = '';
|
|
note = '';
|
|
saving = false;
|
|
|
|
/**
|
|
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
|
|
* no one else to pick on that side. Only the receiving cashier is chosen.
|
|
*/
|
|
ownCashierMissing = false;
|
|
tellerName = '';
|
|
allCashiers: Cashier[] = [];
|
|
selectedTellerId: number | null = null;
|
|
fromCashierId: number | null = null;
|
|
toCashierId: number | null = null;
|
|
fromParty = '';
|
|
toParty = '';
|
|
loadingCashiers = false;
|
|
|
|
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
|
|
fromSummary: CashierSummary | null = null;
|
|
toSummary: CashierSummary | null = null;
|
|
loadingFrom = false;
|
|
loadingTo = false;
|
|
|
|
get denoms(): Denom[] {
|
|
return [
|
|
...this.notes,
|
|
...this.coins
|
|
];
|
|
}
|
|
/** Everyone under the same teller except the sender. */
|
|
get availableTo(): Cashier[] {
|
|
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
const d = businessToday();
|
|
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: any) => ({
|
|
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 = [];
|
|
}
|
|
});
|
|
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
|
|
this.service.getCurrentCashierInfo().subscribe((info) => {
|
|
if (!info) {
|
|
this.ownCashierMissing = true;
|
|
return;
|
|
}
|
|
this.fromCashierId = info.cashierId;
|
|
this.fromParty = info.cashierName;
|
|
this.loadSummary('from');
|
|
});
|
|
}
|
|
|
|
/** The other cashiers at this counter, one of whom receives the cash. */
|
|
private loadReceivers(tellerId: number): void {
|
|
this.loadingCashiers = true;
|
|
this.service
|
|
.getCashiersForTeller(tellerId)
|
|
.pipe(catchError(() => of({ cashiers: [] })))
|
|
.subscribe((r: any) => {
|
|
this.allCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
|
this.loadingCashiers = false;
|
|
});
|
|
}
|
|
|
|
onToSelect(cashierId: number): void {
|
|
this.toCashierId = cashierId;
|
|
const c = this.allCashiers.find((x) => x.id === cashierId);
|
|
this.toParty = c ? c.label : '';
|
|
this.loadSummary('to');
|
|
}
|
|
|
|
onDateChange(value: string): void {
|
|
this.transferDate = value;
|
|
this.loadSummary('from');
|
|
this.loadSummary('to');
|
|
}
|
|
|
|
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
|
|
private loadSummary(side: 'from' | 'to'): void {
|
|
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
|
|
if (side === 'from') {
|
|
this.fromSummary = null;
|
|
} else {
|
|
this.toSummary = null;
|
|
}
|
|
if (!cashierId || !this.transferDate) return;
|
|
this.setLoading(side, true);
|
|
this.service
|
|
.getInterCashierCashierSummary(cashierId, this.transferDate)
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((r: any) => {
|
|
this.setLoading(side, false);
|
|
// A late answer for a cashier no longer selected on that side is dropped.
|
|
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
|
|
if (!r || current !== cashierId) return;
|
|
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
|
|
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
|
|
this.selectedTellerId = Number(r.tellerId);
|
|
this.tellerName = r.tellerName || '';
|
|
this.loadReceivers(this.selectedTellerId);
|
|
}
|
|
const held: Record<number, number> = {};
|
|
for (const d of r.denominations || []) {
|
|
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
|
|
}
|
|
const summary: CashierSummary = {
|
|
cashierId: r.cashierId,
|
|
cashierName: r.cashierName,
|
|
tellerName: r.tellerName,
|
|
officeName: r.officeName,
|
|
cashAllocated: !!r.cashAllocated,
|
|
allocatedAmount: Number(r.allocatedAmount || 0),
|
|
totalAmount: Number(r.totalAmount || 0),
|
|
held
|
|
};
|
|
if (side === 'from') {
|
|
this.fromSummary = summary;
|
|
} else {
|
|
this.toSummary = summary;
|
|
}
|
|
});
|
|
}
|
|
|
|
private setLoading(side: 'from' | 'to', loading: boolean): void {
|
|
if (side === 'from') {
|
|
this.loadingFrom = loading;
|
|
} else {
|
|
this.loadingTo = loading;
|
|
}
|
|
}
|
|
|
|
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
|
|
heldBy(summary: CashierSummary | null, d: Denom): number | null {
|
|
return summary ? summary.held[d.id] || 0 : null;
|
|
}
|
|
|
|
/** The sending cashier is asked to hand on more of this denomination than they hold. */
|
|
exceedsHeld(d: Denom): boolean {
|
|
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
|
|
}
|
|
|
|
get anyExceedsHeld(): boolean {
|
|
return this.denoms.some((d) => this.exceedsHeld(d));
|
|
}
|
|
|
|
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
|
|
get bothAllocated(): boolean {
|
|
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
|
|
}
|
|
|
|
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);
|
|
}
|
|
get valid(): boolean {
|
|
return (
|
|
this.total > 0 &&
|
|
!!this.selectedTellerId &&
|
|
!!this.fromCashierId &&
|
|
!!this.toCashierId &&
|
|
this.bothAllocated &&
|
|
!this.anyExceedsHeld
|
|
);
|
|
}
|
|
|
|
clear(): void {
|
|
this.counts = {};
|
|
}
|
|
|
|
submit(): void {
|
|
if (!this.valid || this.saving) return;
|
|
this.saving = true;
|
|
const payload = {
|
|
transferDate: this.transferDate,
|
|
tellerId: this.selectedTellerId,
|
|
fromCashierId: this.fromCashierId,
|
|
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.createInterCashierTransfer(payload).subscribe({
|
|
next: () => {
|
|
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
|
|
this.clear();
|
|
this.note = '';
|
|
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
|
|
// are read again now that part of them has gone.
|
|
this.toCashierId = null;
|
|
this.toParty = '';
|
|
this.toSummary = null;
|
|
this.loadSummary('from');
|
|
this.saving = false;
|
|
},
|
|
error: () => {
|
|
this.saving = false;
|
|
}
|
|
});
|
|
}
|
|
}
|