Files
cbsnexora/src/app/clerk/withdrawal-request/withdrawal-request.component.ts
T
Jaanu423andClaude Sonnet 4.6 61cb09f352 feat(cashier): complete maker-checker withdrawal disbursement flow
- Clerk manually enters token no with today-date unique validation (backend enforced)
- Cashier token lookup: auto-fill amount/account, lock amount field, status-specific errors
- Confirm dialog shows CASH DISBURSEMENT (blue) with no balance re-deduction warning
- Disbursement denomination saved to m_savings_txn_denomination for vault position update
- Block direct cashier withdrawal without approved maker-checker token
- DISBURSED status shown in My Requests and Checker inbox views with cashier/time info
- Remove getNextToken() service method and m_daily_token_counter table (0326 migration)
- Race condition protection: UPDATE WHERE status=APPROVED prevents double disbursement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:57:33 +05:30

515 lines
17 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 { 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<string, number> = {};
// Reject dialog
showRejectDialog = false;
rejectionReason = '';
// Activity log
activityLog: ActivityEntry[] = [];
// Validation errors
validationErrors: Record<string, string> = {};
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<any>(`/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<any>(`/savingsaccounts/${query}`).pipe(catchError(() => of(null)))
: of(null);
byId$
.pipe(
switchMap((acc: any) => {
if (acc && acc.id) return of(acc);
return this.http
.get<any[]>(`/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<any>(`/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<string, string> = {
SUBMITTED: 'Submitted',
RESUBMITTED: 'Resubmitted',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return labels[action] || action;
}
activityClass(action: string): string {
const map: Record<string, string> = {
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;
}
}
}