/** * 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 { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { catchError, map, switchMap } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from '../../cashier/cashier.service'; import { ClerkWithdrawalService } from '../../cashier/clerk-withdrawal.service'; export type WdMode = 'create' | 'edit' | 'review'; interface Denomination { id: number; value: number; kind: 'note' | 'coin'; label: string; } interface ActivityEntry { timestamp: string; action: string; user: string; note?: string; } type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; @Component({ selector: 'mifosx-withdrawal-request', templateUrl: './withdrawal-request.component.html', styleUrls: [ '../../cashier/cashier-shared.scss', './withdrawal-request.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, RouterModule, MatIconModule, MatSnackBarModule ] }) export class WithdrawalRequestComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private http = inject(HttpClient); private wdService = inject(ClerkWithdrawalService); private cashierService = inject(CashierService); private snackBar = inject(MatSnackBar); mode: WdMode = 'create'; requestId: number | null = null; existingRequest: any = null; pageState: PageState = 'idle'; accountInput = ''; account: any = null; errorMsg = ''; // Form fields amount: number | null = null; txnDate = ''; paymentTypeId: number | null = null; description = ''; chequeNo = ''; bankName = ''; tokNo = ''; // Denomination paymentTypes: any[] = []; notes: Denomination[] = []; coins: Denomination[] = []; denominationCounts: Record = {}; // Reject dialog showRejectDialog = false; rejectionReason = ''; // Activity log activityLog: ActivityEntry[] = []; // Validation errors validationErrors: Record = {}; get isCashPayment(): boolean { if (!this.paymentTypeId) return true; const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); return !pt || !!pt.isCashPayment; } get isChequePayment(): boolean { if (!this.paymentTypeId) return false; const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); return pt?.name?.toLowerCase().includes('cheque') || pt?.name?.toLowerCase().includes('check') || false; } get allDenominations(): Denomination[] { return [ ...this.notes, ...this.coins ]; } get denominationTotal(): number { return this.allDenominations.reduce((s, d) => s + this.countFor(d) * d.value, 0); } get denomMatchesAmount(): boolean { return this.denominationTotal === (this.amount || 0); } get balanceAfterWithdrawal(): number { return (this.account?.balance ?? 0) - (this.amount || 0); } get isFormValid(): boolean { if (!this.account) return false; if (!this.amount || this.amount <= 0) return false; if (!this.txnDate) return false; if (this.account?.balance != null && (this.amount || 0) > this.account.balance) return false; if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) return false; return true; } get isReview(): boolean { return this.mode === 'review'; } get isEdit(): boolean { return this.mode === 'edit'; } get isCreate(): boolean { return this.mode === 'create'; } get submitLabel(): string { if (this.pageState === 'submitting') return 'Submitting…'; return this.mode === 'edit' ? 'Resubmit for Approval' : 'Submit for Approval'; } get pageTitle(): string { if (this.isReview) return 'Review Withdrawal Request'; if (this.isEdit) return 'Edit Withdrawal Request'; return 'New Withdrawal Request'; } get pageSubtitle(): string { if (this.isReview) return "Approve or reject the clerk's withdrawal request."; if (this.isEdit) return 'Update the rejected request and resubmit for approval.'; return 'Submit a cash withdrawal request for Bank Manager approval.'; } ngOnInit(): void { this.mode = (this.route.snapshot.data?.['mode'] as WdMode) ?? 'create'; const idParam = this.route.snapshot.params?.['id']; this.requestId = idParam ? Number(idParam) : null; const today = new Date(); this.txnDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; this.cashierService .getPaymentTypes() .pipe(catchError(() => of([]))) .subscribe((types: any[]) => (this.paymentTypes = types || [])); this.loadDenominations(); if (this.requestId && (this.mode === 'edit' || this.mode === 'review')) { this.loadRequest(this.requestId); } } private loadDenominations(): void { this.cashierService .getActiveDenominations() .pipe(catchError(() => of([]))) .subscribe((list: any[]) => { const rows: Denomination[] = (list || []).map((d: any) => ({ id: d.id, value: d.denomination ?? d.value, kind: d.type === 'coin' ? 'coin' : 'note', label: '₹' + (d.denomination ?? d.value) })); const order = (a: Denomination, b: Denomination) => b.value - a.value; this.notes = rows.filter((r) => r.kind === 'note').sort(order); this.coins = rows.filter((r) => r.kind === 'coin').sort(order); }); } private loadRequest(id: number): void { this.pageState = 'searching'; this.wdService.getById(id).subscribe({ next: (req: any) => { this.existingRequest = req; this.amount = Number(req.amount); this.txnDate = req.txn_date ? String(req.txn_date).split('T')[0].split(' ')[0] : this.txnDate; this.paymentTypeId = req.payment_type_id ? Number(req.payment_type_id) : null; this.description = req.description ?? ''; this.chequeNo = req.cheque_no ?? ''; this.bankName = req.bank_name ?? ''; this.tokNo = req.tok_no ?? ''; if (req.denomination_data) { try { this.denominationCounts = JSON.parse(req.denomination_data); } catch { /* ignore */ } } if (req.activity_log) { try { this.activityLog = JSON.parse(req.activity_log); } catch { this.activityLog = []; } } this.account = { id: req.savings_account_id ? Number(req.savings_account_id) : null, accountNo: req.account_no, clientName: req.client_name, clientId: req.client_id ? Number(req.client_id) : null, accountType: req.account_type, balance: null }; this.accountInput = req.account_no; // Load live balance from savings account if ID is available if (this.account.id) { this.http .get(`/savingsaccounts/${this.account.id}`) .pipe(catchError(() => of(null))) .subscribe((acc: any) => { if (acc) { this.account.balance = Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0); } }); } this.pageState = 'loaded'; }, error: () => { this.snackBar.open('Failed to load withdrawal request.', 'Close', { duration: 5000 }); this.pageState = 'idle'; } }); } searchAccount(): void { const query = this.accountInput.trim(); if (!query) return; this.pageState = 'searching'; this.account = null; this.errorMsg = ''; const byId$ = /^\d+$/.test(query) ? this.http.get(`/savingsaccounts/${query}`).pipe(catchError(() => of(null))) : of(null); byId$ .pipe( switchMap((acc: any) => { if (acc && acc.id) return of(acc); return this.http .get(`/search?query=${encodeURIComponent(query)}&resource=savingsaccount`) .pipe(catchError(() => of([]))) .pipe( map((results: any[]) => (results || []).find((r: any) => r.entityId)), switchMap((r: any) => r?.entityId ? this.http.get(`/savingsaccounts/${r.entityId}`).pipe(catchError(() => of(null))) : of(null) ) ); }) ) .subscribe({ next: (acc: any) => { if (!acc || !acc.id) { this.errorMsg = 'Account not found. Please check the account number or ID.'; this.pageState = 'idle'; return; } this.account = { id: acc.id, accountNo: acc.accountNo, clientName: acc.clientName ?? acc.summary?.clientName, clientId: acc.clientId, accountType: acc.depositType?.value ?? 'SAVINGS', balance: Number(acc.summary?.availableBalance ?? acc.summary?.accountBalance ?? 0), currency: acc.currency?.displaySymbol ?? '₹', status: acc.status?.value ?? 'Active', productName: acc.savingsProductName ?? '' }; this.pageState = 'loaded'; } }); } // ── Denomination helpers ────────────────────────────────────────────────── denomKey(d: Denomination): string { return `${d.kind}:${d.value}`; } countFor(d: Denomination): number { return this.denominationCounts[this.denomKey(d)] || 0; } subtotalFor(d: Denomination): number { return this.countFor(d) * d.value; } onDenomChange(d: Denomination, raw: any): void { this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); } bumpDenom(d: Denomination, delta: number): void { this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); } clearDenominations(): void { this.denominationCounts = {}; } // ── Validation ──────────────────────────────────────────────────────────── validate(): boolean { this.validationErrors = {}; if (!this.account) { this.validationErrors['account'] = 'Account is required. Please search and select an account.'; } if (!this.amount || this.amount <= 0) { this.validationErrors['amount'] = 'Amount must be greater than zero.'; } if (this.account?.balance != null && (this.amount || 0) > this.account.balance) { this.validationErrors['amount'] = `Insufficient balance. Available: ₹${this.account.balance.toFixed(2)}`; } if (!this.txnDate) { this.validationErrors['txnDate'] = 'Transaction date is required.'; } if (this.isCashPayment && this.allDenominations.length > 0 && !this.denomMatchesAmount) { this.validationErrors['denom'] = `Denomination total (₹${this.denominationTotal}) does not match amount (₹${this.amount}).`; } return Object.keys(this.validationErrors).length === 0; } // ── Actions ──────────────────────────────────────────────────────────────── submit(): void { if (!this.validate() || this.pageState === 'submitting') return; const payload = { accountNo: this.account.accountNo, accountType: this.account.accountType || 'SAVINGS', savingsAccountId: this.account.id, clientId: this.account.clientId, clientName: this.account.clientName, amount: this.amount, txnDate: this.txnDate, paymentTypeId: this.paymentTypeId || null, paymentTypeName: this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash', description: this.description || null, chequeNo: this.chequeNo || null, bankName: this.bankName || null, tokNo: this.tokNo?.trim() || null, denominationData: JSON.stringify(this.denominationCounts) }; this.pageState = 'submitting'; const request$ = this.mode === 'edit' && this.requestId ? this.wdService.update(this.requestId, payload) : this.wdService.submit(payload); request$.subscribe({ next: () => { this.pageState = 'success'; const msg = this.mode === 'edit' ? 'Request resubmitted for approval.' : 'Withdrawal request submitted for approval.'; this.snackBar.open(msg, 'Close', { duration: 3000 }); setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1500); }, error: (err: any) => { const msg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to submit request. Please try again.'; if (/token/i.test(msg)) { this.validationErrors['tokNo'] = msg; } this.snackBar.open(msg, 'Close', { duration: 8000 }); this.pageState = 'loaded'; } }); } openRejectDialog(): void { this.rejectionReason = ''; this.showRejectDialog = true; } cancelReject(): void { this.showRejectDialog = false; this.rejectionReason = ''; } approve(): void { if (!this.requestId || this.pageState === 'submitting') return; this.pageState = 'submitting'; this.wdService.approve(this.requestId).subscribe({ next: (res: any) => { this.pageState = 'success'; this.snackBar.open('Withdrawal request approved successfully.', 'Close', { duration: 3000 }); setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500); }, error: (err: any) => { const msg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Approval failed. Please try again.'; this.snackBar.open(msg, 'Close', { duration: 8000 }); this.pageState = 'loaded'; } }); } confirmReject(): void { if (!this.requestId || !this.rejectionReason.trim() || this.pageState === 'submitting') return; this.pageState = 'submitting'; this.wdService.reject(this.requestId, this.rejectionReason.trim()).subscribe({ next: () => { this.showRejectDialog = false; this.pageState = 'success'; this.snackBar.open('Withdrawal request rejected.', 'Close', { duration: 3000 }); setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1500); }, error: (err: any) => { const msg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Rejection failed. Please try again.'; this.snackBar.open(msg, 'Close', { duration: 8000 }); this.pageState = 'loaded'; } }); } goBack(): void { if (this.isReview) { this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']); } else { this.router.navigate(['/clerk/withdrawal-requests']); } } // ── Activity log helpers ────────────────────────────────────────────────── activityLabel(action: string): string { const labels: Record = { SUBMITTED: 'Submitted', RESUBMITTED: 'Resubmitted', APPROVED: 'Approved', REJECTED: 'Rejected' }; return labels[action] || action; } activityClass(action: string): string { const map: Record = { SUBMITTED: 'wdr-activity--submitted', RESUBMITTED: 'wdr-activity--resubmitted', APPROVED: 'wdr-activity--approved', REJECTED: 'wdr-activity--rejected' }; return map[action] || ''; } resolvePaymentTypeName(): string { if (!this.paymentTypeId) return 'Cash'; return this.paymentTypes.find((p) => p.id === this.paymentTypeId)?.name ?? 'Cash'; } formatActivityTime(ts: string): string { try { const d = new Date(ts); return d.toLocaleString('en-IN', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return ts; } } }