/** * 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 { MatInputModule } from '@angular/material/input'; import { MatTabsModule } from '@angular/material/tabs'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; interface DenomRow { denominationId: number; denominationValue: number; kind: string; morningCount: number; expectedCount: number; physicalCount: number; varianceCount: number; } interface ReconRecord { id: number; reconDate: string; cashierId: number; cashierName: string; expectedAmount: number; physicalAmount: number; variance: number; status: 'OK' | 'SHORT' | 'EXCESS'; performedByUsername: string; createdAt: string; } /** Denomination-wise reconciliation — expected vs physical count per cashier. */ @Component({ selector: 'mifosx-reconciliation', templateUrl: './reconciliation.component.html', styleUrls: [ '../cashier-shared.scss', './reconciliation.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatSnackBarModule, MatFormFieldModule, MatInputModule, MatTabsModule ] }) export class ReconciliationComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); /** * A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is * no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else. */ ownCashierMissing = false; ownTellerName = ''; // ── Entry form ── selectedTellerId: number | null = null; selectedCashierId: number | null = null; cashierName = ''; reconDate = ''; note = ''; varianceReason = ''; rows: DenomRow[] = []; morningTotal = 0; loadingExpected = false; saving = false; // ── History list ── history: ReconRecord[] = []; loadingHistory = false; ngOnInit(): void { const d = businessToday(); this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.lockToOwnCashier(); this.loadHistory(); } /** Take the logged-in cashier as the one being reconciled, with their counter for the record. */ private lockToOwnCashier(): void { this.service.getCurrentCashierInfo().subscribe((info) => { if (!info) { this.ownCashierMissing = true; return; } this.selectedCashierId = info.cashierId; this.cashierName = info.cashierName; this.service .getInterCashierCashierSummary(info.cashierId, this.reconDate) .pipe(catchError(() => of(null))) .subscribe((r: any) => { this.selectedTellerId = r?.tellerId ?? null; this.ownTellerName = r?.tellerName || ''; }); this.fetchExpected(); }); } fetchExpected(): void { if (!this.selectedCashierId) return; this.loadingExpected = true; forkJoin({ expected: this.service .getExpectedBalance(this.selectedCashierId, this.reconDate) .pipe(catchError(() => of(null))), morning: this.service .getCashierMorningBalance(this.selectedCashierId, this.reconDate) .pipe(catchError(() => of(null))) }).subscribe(({ expected, morning }) => { this.loadingExpected = false; const morningMap = new Map(); ((morning?.denominations || []) as any[]).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0) ); this.morningTotal = Number(morning?.totalMorning ?? 0); if (!expected) return; this.rows = ((expected.denominations || []) as any[]) .sort((a: any, b: any) => b.denominationValue - a.denominationValue) .map((d: any) => ({ denominationId: d.denominationId || null, denominationValue: d.denominationValue, kind: d.kind || 'note', morningCount: morningMap.get(d.denominationId) ?? 0, expectedCount: d.expectedCount || 0, physicalCount: d.expectedCount || 0, varianceCount: 0 })); }); } updatePhysical(row: DenomRow, raw: any): void { row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0)); row.varianceCount = row.physicalCount - row.expectedCount; } get noteRows(): DenomRow[] { return this.rows.filter((r) => r.kind !== 'coin'); } get coinRows(): DenomRow[] { 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); } 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 statusLabel(): string { const v = this.totalVariance; return v === 0 ? 'OK' : v > 0 ? 'EXCESS' : 'SHORT'; } get valid(): boolean { return ( !!this.selectedCashierId && this.rows.length > 0 && (this.totalVariance === 0 || this.varianceReason.trim().length > 0) ); } submit(): void { if (!this.valid || this.saving) return; this.saving = true; const payload = { cashierId: this.selectedCashierId, tellerId: this.selectedTellerId, cashierName: this.cashierName, reconDate: this.reconDate, note: [ this.note, this.totalVariance !== 0 ? `Variance reason: ${this.varianceReason}` : null ] .filter(Boolean) .join(' | ') || null, denominations: this.rows.map((r) => ({ denominationId: r.denominationId, denominationValue: r.denominationValue, expectedCount: r.expectedCount, physicalCount: r.physicalCount })) }; this.service.createReconciliation(payload).subscribe({ next: (res: any) => { const status = res?.status || 'OK'; this.snackBar.open(`Reconciliation saved — Status: ${status}`, 'Close', { duration: 4000, panelClass: status === 'OK' ? ['snack-ok'] : ['snack-warn'] }); this.rows = []; this.note = ''; this.varianceReason = ''; this.saving = false; this.loadHistory(); }, error: () => { this.saving = false; } }); } loadHistory(): void { this.loadingHistory = true; this.service .getReconciliations() .pipe(catchError(() => of([]))) .subscribe((list: any[]) => { this.history = list || []; this.loadingHistory = false; }); } statusClass(status: string): string { return status === 'OK' ? 'badge-ok' : status === 'EXCESS' ? 'badge-excess' : 'badge-short'; } }