- 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>
515 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|