Files
cbsnexora/src/app/cashier/cash-settlement/cash-settlement.component.ts
T

298 lines
9.9 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 { Router, RouterModule } from '@angular/router';
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 { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
interface SettleDenomRow {
denominationId: number;
denominationValue: number;
kind: 'note' | 'coin';
morningCount: number;
expectedCount: number;
physicalCount: number;
}
/**
* End-of-day cash settlement with full validation:
* - Auto-detects logged-in cashier via /cashreports/cashier-position/me
* - Supervisor/admin mode: manual teller + cashier selection when not a cashier
* - Blocks if reconciliation not done today
* - Blocks if already settled today
* - Pre-fills physical counts from system expected balance
* - Requires variance reason when physical ≠ expected
* - Backend enforces reconciliation-before-settlement and no duplicate per day
*/
@Component({
selector: 'mifosx-cash-settlement',
templateUrl: './cash-settlement.component.html',
styleUrls: [
'../cashier-shared.scss',
'./cash-settlement.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class CashSettlementComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private router = inject(Router);
pageState: PageState = 'loading';
isSupervisor = false;
// Cashier being settled (auto-detected for cashier, manually selected for admin)
myCashierId: number | null = null;
myCashierName = '';
// Supervisor mode: teller selector to filter cashiers
supervisorTellerId: number | null = null;
supervisorCashiers: { id: number; label: string }[] = [];
loadingCashiers = false;
supervisorChecking = false;
rows: SettleDenomRow[] = [];
morningTotal = 0;
settlementDate = '';
// Vault teller to deposit TO
tellers: { id: number; name: string }[] = [];
selectedTellerId: number | null = null;
toParty = '';
note = '';
varianceReason = '';
saving = false;
ngOnInit(): void {
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list: any[]) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
this.onSupervisorTellerSelect(this.tellers[0].id);
}
});
this.loadData();
}
loadData(): void {
this.pageState = 'loading';
this.rows = [];
this.service
.getCashierPositionForMe(this.settlementDate)
.pipe(catchError(() => of(null)))
.subscribe((pos: any) => {
if (!pos?.cashierId) {
// Not a cashier — supervisor/admin mode
this.isSupervisor = true;
this.pageState = 'no-cashier';
return;
}
this.isSupervisor = false;
this.myCashierId = pos.cashierId;
this.myCashierName = pos.cashierName || '';
this.loadCashierChecks(pos.cashierId);
});
}
// Called after cashier is known (auto or supervisor-selected)
private loadCashierChecks(cashierId: number): void {
forkJoin({
expected: this.service.getExpectedBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))),
check: this.service
.checkSettlement(cashierId, this.settlementDate)
.pipe(catchError(() => of({ alreadySettled: false }))),
recon: this.service
.checkReconciliation(cashierId, this.settlementDate)
.pipe(catchError(() => of({ reconciled: false }))),
morning: this.service.getCashierMorningBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null)))
}).subscribe(({ expected, check, recon, morning }) => {
this.supervisorChecking = false;
if (check?.alreadySettled) {
this.pageState = 'already-settled';
return;
}
if (!recon?.reconciled) {
this.pageState = 'needs-reconciliation';
return;
}
// Build denomination → morningCount map
const morningMap = new Map<number, number>();
((morning?.denominations || []) as any[]).forEach((d: any) =>
morningMap.set(d.denominationId, d.morningCount ?? 0)
);
this.morningTotal = Number(morning?.totalMorning ?? 0);
this.rows = ((expected?.denominations as any[]) || [])
.filter((d: any) => (d.expectedCount ?? 0) > 0)
.sort((a: any, b: any) => b.denominationValue - a.denominationValue)
.map((d: any) => ({
denominationId: d.denominationId,
denominationValue: d.denominationValue,
kind: (d.kind ?? 'note') as 'note' | 'coin',
morningCount: morningMap.get(d.denominationId) ?? 0,
expectedCount: d.expectedCount,
physicalCount: d.expectedCount
}));
this.pageState = 'form';
});
}
// Supervisor: teller selected → load cashiers for that teller
onSupervisorTellerSelect(tellerId: number): void {
this.supervisorTellerId = tellerId;
this.supervisorCashiers = [];
this.myCashierId = null;
this.myCashierName = '';
this.rows = [];
if (!tellerId) return;
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.loadingCashiers = false;
this.supervisorCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
});
}
// Supervisor: cashier selected → run checks
onSupervisorCashierSelect(cashierId: number): void {
const c = this.supervisorCashiers.find((x) => x.id === cashierId);
this.myCashierId = cashierId;
this.myCashierName = c ? c.label : '';
this.rows = [];
this.supervisorChecking = true;
this.loadCashierChecks(cashierId);
}
get noteRows(): SettleDenomRow[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
get coinRows(): SettleDenomRow[] {
return this.rows.filter((r) => r.kind === 'coin');
}
get morningNoteTotal(): number {
return this.noteRows.reduce((s, r) => s + r.morningCount * r.denominationValue, 0);
}
get morningCoinTotal(): number {
return this.coinRows.reduce((s, r) => s + r.morningCount * r.denominationValue, 0);
}
varianceFor(row: SettleDenomRow): number {
return row.physicalCount - row.expectedCount;
}
get totalExpected(): number {
return this.rows.reduce((s, r) => s + r.denominationValue * r.expectedCount, 0);
}
get totalPhysical(): number {
return this.rows.reduce((s, r) => s + r.denominationValue * r.physicalCount, 0);
}
get totalVariance(): number {
return this.totalPhysical - this.totalExpected;
}
get hasVariance(): boolean {
return this.totalVariance !== 0;
}
updatePhysical(row: SettleDenomRow, raw: any): void {
row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0));
}
bump(row: SettleDenomRow, delta: number): void {
row.physicalCount = Math.max(0, row.physicalCount + delta);
}
goToReconciliation(): void {
this.router.navigate(['/cashier/reconciliation']);
}
onTellerSelect(id: number): void {
this.selectedTellerId = id;
const t = this.tellers.find((x) => x.id === id);
this.toParty = t ? t.name : '';
}
get valid(): boolean {
return (
!!this.selectedTellerId && this.totalPhysical > 0 && (!this.hasVariance || this.varianceReason.trim().length > 0)
);
}
submit(): void {
if (!this.valid || this.saving) return;
this.saving = true;
const payload = {
settlementDate: this.settlementDate,
fromCashierId: this.myCashierId,
toTellerId: this.selectedTellerId,
fromParty: this.myCashierName,
toParty: this.toParty,
note:
[
this.note,
this.hasVariance ? `Variance reason: ${this.varianceReason}` : null
]
.filter(Boolean)
.join(' | ') || null,
denominations: this.rows
.filter((r) => r.physicalCount > 0)
.map((r) => ({
denominationId: r.denominationId,
denominationValue: r.denominationValue,
count: r.physicalCount
}))
};
this.service.createCashSettlement(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Settlement saved (GL warning: ${res.glWarning})`, 'Close', { duration: 6000 });
} else {
this.snackBar.open('Cash settlement recorded successfully', 'Close', { duration: 3000 });
}
this.pageState = 'already-settled';
this.saving = false;
},
error: (err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Settlement failed. Please try again.';
this.snackBar.open(msg, 'Close', { duration: 6000 });
this.saving = false;
}
});
}
}