/** * 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, TemplateRef, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; /** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */ interface CashTransferRequest { id: number; transfer_date: string | null; from_party: string | null; to_party: string | null; note: string | null; total_amount: number; maker_name: string | null; maker_at: string | null; denominations: { denominationValue: number; count: number }[]; } /** * Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash * leaves the vault only when it is approved, by someone other than the person who raised it. */ @Component({ selector: 'mifosx-cash-transfer-approvals', templateUrl: './cash-transfer-approvals.component.html', styleUrls: ['./cash-transfer-approvals.component.scss'], standalone: true, imports: [ CommonModule, FormsModule, MatDialogModule, MatIconModule, MatSnackBarModule ] }) export class CashTransferApprovalsComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); private dialog = inject(MatDialog); @ViewChild('rejectDialog') rejectDialog!: TemplateRef; @ViewChild('reviewDialog') reviewDialog!: TemplateRef; private dialogRef: MatDialogRef | null = null; private reviewRef: MatDialogRef | null = null; /** The request open in the review popup, with every note and coin it asks for. */ reviewing: CashTransferRequest | null = null; all: CashTransferRequest[] = []; loading = false; loaded = false; busyId: number | null = null; searchTerm = ''; rejecting: CashTransferRequest | null = null; rejectReason = ''; ngOnInit(): void { this.load(); } load(): void { this.loading = true; this.service .getCashTransferRequests('PENDING') .pipe(catchError(() => of([] as CashTransferRequest[]))) .subscribe((rows) => { this.all = rows as CashTransferRequest[]; this.loading = false; this.loaded = true; }); } get rows(): CashTransferRequest[] { const term = this.searchTerm.trim().toLowerCase(); if (!term) return this.all; return this.all.filter((r) => [ r.from_party, r.to_party, r.maker_name, r.note ].some((v) => (v ?? '').toLowerCase().includes(term))); } /** One line per denomination asked for, largest first, with what each line adds up to. */ linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] { return [...(r.denominations ?? [])] .filter((d) => d.count > 0) .sort((a, b) => b.denominationValue - a.denominationValue) .map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count })); } /** How many notes and coins the request asks for in all. */ piecesOf(r: CashTransferRequest): number { return this.linesOf(r).reduce((sum, l) => sum + l.count, 0); } review(r: CashTransferRequest): void { this.reviewing = r; this.reviewRef?.close(); this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false }); this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null)); } closeReview(): void { this.reviewRef?.close(); } /** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */ rejectFromReview(r: CashTransferRequest): void { this.reviewRef?.close(); this.startReject(r); } formatDateTime(value: string | null): string { if (!value) return '—'; const d = new Date(value); return isNaN(d.getTime()) ? value : d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } approve(r: CashTransferRequest): void { if (this.busyId) return; this.busyId = r.id; this.service.approveCashTransferRequest(r.id).subscribe({ next: (res: any) => { this.busyId = null; this.reviewRef?.close(); this.snackBar.open( res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier', 'Close', { duration: 6000 } ); this.load(); }, error: (err) => { this.busyId = null; this.fail(err, 'Could not approve'); } }); } startReject(r: CashTransferRequest): void { this.rejecting = r; this.rejectReason = ''; this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false }); this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null)); } cancelReject(): void { this.dialogRef?.close(); } confirmReject(): void { if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return; const id = this.rejecting.id; this.busyId = id; this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({ next: () => { this.busyId = null; this.dialogRef?.close(); this.snackBar.open('Rejected', 'Close', { duration: 4000 }); this.load(); }, error: (err) => { this.busyId = null; this.fail(err, 'Could not reject'); } }); } private fail(err: any, fallback: string): void { const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; this.snackBar.open(msg, 'Close', { duration: 8000 }); } }