Cash claims now require a token at claim submission; a cashier redeems it at Transaction Entry to record physical hand-over, mirroring the existing clerk-withdrawal-request flow -- no double-disbursement, and the manager never hands over cash directly at approval time. Also: dividend-claim tokens fall back through the same TOK. NO. lookup used for withdrawal requests, silenced against the global error interceptor since the caller already handles every outcome; the account summary card now shows "Dividend Claim" instead of a misleading "A/c Savings" label when resolving one; and cash amounts are floored to whole rupees client-side to match cash's whole-rupee denominations. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
360 lines
12 KiB
TypeScript
360 lines
12 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 { catchError } from 'rxjs/operators';
|
|
import { of } from 'rxjs';
|
|
|
|
import { DividendDashboardService } from '../dividend-dashboard.service';
|
|
import { DividendClaimRequestService } from '../dividend-claim-request.service';
|
|
|
|
/**
|
|
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
|
|
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
|
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
|
|
* claim their dividend directly (split across loan, savings, or cash).
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-dividend-dashboard',
|
|
templateUrl: './dividend-dashboard.component.html',
|
|
styleUrls: [
|
|
'../../cashier/cashier-shared.scss',
|
|
'../clerk-shared.scss',
|
|
'./dividend-dashboard.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule
|
|
]
|
|
})
|
|
export class DividendDashboardComponent implements OnInit {
|
|
private service = inject(DividendDashboardService);
|
|
private claimRequestService = inject(DividendClaimRequestService);
|
|
|
|
rows: any[] = [];
|
|
loading = false;
|
|
loaded = false;
|
|
|
|
expandedLockId: number | null = null;
|
|
selectedLoanId: Record<number, number | null> = {};
|
|
applyAmount: Record<number, number | null> = {};
|
|
applying = false;
|
|
applyError: string | null = null;
|
|
|
|
summary: any = null;
|
|
summaryLoading = false;
|
|
|
|
declarations: any[] = [];
|
|
declarationsLoading = false;
|
|
declarationsLoaded = false;
|
|
|
|
locks: any[] = [];
|
|
locksLoading = false;
|
|
locksLoaded = false;
|
|
lockStatusFilter = 'ALL';
|
|
|
|
expandedClaimLockId: number | null = null;
|
|
claimOverdueLoans: Record<number, any[]> = {};
|
|
claimLoanId: Record<number, number | null> = {};
|
|
claimLoanAmount: Record<number, number | null> = {};
|
|
claimSavingsAmount: Record<number, number | null> = {};
|
|
claimSavingsAccountOverride: Record<number, number | null> = {};
|
|
claimCashAmount: Record<number, number | null> = {};
|
|
claimTokNo: Record<number, string> = {};
|
|
claimSubmitting = false;
|
|
claimError: string | null = null;
|
|
|
|
claimRequests: any[] = [];
|
|
claimRequestsLoading = false;
|
|
claimRequestsLoaded = false;
|
|
claimActioningId: number | null = null;
|
|
claimActionError: Record<number, string> = {};
|
|
expandedRejectClaimId: number | null = null;
|
|
rejectReason: Record<number, string> = {};
|
|
|
|
ngOnInit(): void {
|
|
this.loadSummary();
|
|
this.loadDeclarations();
|
|
this.load();
|
|
this.loadLocks();
|
|
this.loadClaimRequests();
|
|
}
|
|
|
|
toggleClaim(lock: any): void {
|
|
if (this.expandedClaimLockId === lock.lockId) {
|
|
this.expandedClaimLockId = null;
|
|
return;
|
|
}
|
|
this.expandedClaimLockId = lock.lockId;
|
|
this.claimError = null;
|
|
this.claimLoanId[lock.lockId] = null;
|
|
this.claimLoanAmount[lock.lockId] = null;
|
|
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
|
|
this.claimSavingsAccountOverride[lock.lockId] = null;
|
|
this.claimCashAmount[lock.lockId] = null;
|
|
this.claimTokNo[lock.lockId] = '';
|
|
this.claimRequestService
|
|
.getOverdueLoans(lock.clientId)
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((loans: any[]) => {
|
|
this.claimOverdueLoans[lock.lockId] = loans ?? [];
|
|
if (loans && loans.length > 0) {
|
|
this.claimLoanId[lock.lockId] = loans[0].loanId;
|
|
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
|
|
this.claimLoanAmount[lock.lockId] = loanAmt;
|
|
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
|
|
}
|
|
});
|
|
}
|
|
|
|
claimTotal(lock: any): number {
|
|
return (
|
|
Number(this.claimLoanAmount[lock.lockId] || 0) +
|
|
Number(this.claimSavingsAmount[lock.lockId] || 0) +
|
|
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
|
|
);
|
|
}
|
|
|
|
submitClaim(lock: any): void {
|
|
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
|
|
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
|
|
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
|
|
// dividend share often works out to paise — floor to the nearest rupee so the denomination
|
|
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
|
|
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
|
|
const total = loanAmount + savingsAmount + cashAmount;
|
|
if (total <= 0) {
|
|
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
|
|
return;
|
|
}
|
|
if (total > Number(lock.amount)) {
|
|
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
|
|
return;
|
|
}
|
|
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
|
|
this.claimError = 'Select a loan to apply the loan amount towards.';
|
|
return;
|
|
}
|
|
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
|
|
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
|
|
return;
|
|
}
|
|
const tokNo = this.claimTokNo[lock.lockId]?.trim();
|
|
if (cashAmount > 0 && !tokNo) {
|
|
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
|
|
return;
|
|
}
|
|
this.claimSubmitting = true;
|
|
this.claimError = null;
|
|
this.claimRequestService
|
|
.submit(lock.lockId, {
|
|
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
|
|
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
|
|
savingsAccountId:
|
|
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
|
|
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
|
|
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
|
|
tokNo: cashAmount > 0 ? tokNo : undefined
|
|
})
|
|
.subscribe({
|
|
next: () => {
|
|
this.claimSubmitting = false;
|
|
this.expandedClaimLockId = null;
|
|
this.loadClaimRequests();
|
|
},
|
|
error: (err: any) => {
|
|
this.claimSubmitting = false;
|
|
this.claimError = err?.error?.message || 'Failed to submit claim request.';
|
|
}
|
|
});
|
|
}
|
|
|
|
loadClaimRequests(): void {
|
|
this.claimRequestsLoading = true;
|
|
this.claimRequestService
|
|
.list('PENDING')
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((res: any[]) => {
|
|
this.claimRequests = res ?? [];
|
|
this.claimRequestsLoading = false;
|
|
this.claimRequestsLoaded = true;
|
|
});
|
|
}
|
|
|
|
approveClaim(req: any): void {
|
|
this.claimActioningId = req.id;
|
|
delete this.claimActionError[req.id];
|
|
this.claimRequestService.approve(req.id).subscribe({
|
|
next: () => {
|
|
this.claimActioningId = null;
|
|
this.loadClaimRequests();
|
|
this.loadLocks();
|
|
this.loadSummary();
|
|
},
|
|
error: (err: any) => {
|
|
this.claimActioningId = null;
|
|
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleRejectClaim(req: any): void {
|
|
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
|
|
}
|
|
|
|
rejectClaim(req: any): void {
|
|
const reason = this.rejectReason[req.id];
|
|
if (!reason || !reason.trim()) {
|
|
this.claimActionError[req.id] = 'Rejection reason is required.';
|
|
return;
|
|
}
|
|
this.claimActioningId = req.id;
|
|
delete this.claimActionError[req.id];
|
|
this.claimRequestService.reject(req.id, reason).subscribe({
|
|
next: () => {
|
|
this.claimActioningId = null;
|
|
this.expandedRejectClaimId = null;
|
|
this.loadClaimRequests();
|
|
},
|
|
error: (err: any) => {
|
|
this.claimActioningId = null;
|
|
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
|
|
}
|
|
});
|
|
}
|
|
|
|
loadSummary(): void {
|
|
this.summaryLoading = true;
|
|
this.service
|
|
.getSummary()
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((res: any) => {
|
|
this.summary = res;
|
|
this.summaryLoading = false;
|
|
});
|
|
}
|
|
|
|
loadDeclarations(): void {
|
|
this.declarationsLoading = true;
|
|
this.service
|
|
.getDeclarations()
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((res: any[]) => {
|
|
this.declarations = res ?? [];
|
|
this.declarationsLoading = false;
|
|
this.declarationsLoaded = true;
|
|
});
|
|
}
|
|
|
|
declarationStatusLabel(status: string): string {
|
|
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
|
|
}
|
|
|
|
declarationStatusClass(status: string): string {
|
|
return status === 'PENDING_APPROVAL' ? 'dd-badge--pending' : 'dd-badge--posted';
|
|
}
|
|
|
|
loadLocks(): void {
|
|
this.locksLoading = true;
|
|
this.service
|
|
.getLocks(this.lockStatusFilter)
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((res: any[]) => {
|
|
this.locks = res ?? [];
|
|
this.locksLoading = false;
|
|
this.locksLoaded = true;
|
|
});
|
|
}
|
|
|
|
setLockStatusFilter(status: string): void {
|
|
this.lockStatusFilter = status;
|
|
this.loadLocks();
|
|
}
|
|
|
|
lockStatusClass(status: string): string {
|
|
switch (status) {
|
|
case 'UNCLAIMED':
|
|
return 'dd-badge--pending';
|
|
case 'CLAIMED':
|
|
return 'dd-badge--posted';
|
|
case 'FORFEITED':
|
|
return 'dd-badge--failed';
|
|
default:
|
|
return 'dd-badge--pending';
|
|
}
|
|
}
|
|
|
|
load(): void {
|
|
this.loading = true;
|
|
this.expandedLockId = null;
|
|
this.service
|
|
.getDashboard()
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((res: any[]) => {
|
|
this.rows = res ?? [];
|
|
this.loading = false;
|
|
this.loaded = true;
|
|
});
|
|
}
|
|
|
|
totalOverdue(row: any): number {
|
|
return (row.overdueLoans || []).reduce((sum: number, l: any) => sum + Number(l.overdueAmount || 0), 0);
|
|
}
|
|
|
|
toggleApply(row: any): void {
|
|
if (this.expandedLockId === row.lockId) {
|
|
this.expandedLockId = null;
|
|
return;
|
|
}
|
|
this.expandedLockId = row.lockId;
|
|
this.applyError = null;
|
|
const firstLoan = (row.overdueLoans || [])[0];
|
|
this.selectedLoanId[row.lockId] = firstLoan ? firstLoan.loanId : null;
|
|
this.applyAmount[row.lockId] = firstLoan
|
|
? Math.min(Number(row.lockedAmount), Number(firstLoan.overdueAmount))
|
|
: Number(row.lockedAmount);
|
|
}
|
|
|
|
maxAmountFor(row: any): number {
|
|
const loanId = this.selectedLoanId[row.lockId];
|
|
const loan = (row.overdueLoans || []).find((l: any) => l.loanId === loanId);
|
|
const overdue = loan ? Number(loan.overdueAmount) : Number(row.lockedAmount);
|
|
return Math.min(Number(row.lockedAmount), overdue);
|
|
}
|
|
|
|
onLoanChange(row: any): void {
|
|
this.applyAmount[row.lockId] = this.maxAmountFor(row);
|
|
}
|
|
|
|
submitApply(row: any): void {
|
|
const loanId = this.selectedLoanId[row.lockId];
|
|
const amount = this.applyAmount[row.lockId];
|
|
if (!loanId || !amount || amount <= 0) {
|
|
this.applyError = 'Select a loan and enter a valid amount.';
|
|
return;
|
|
}
|
|
this.applying = true;
|
|
this.applyError = null;
|
|
this.service.applyToLoan(row.lockId, loanId, amount).subscribe({
|
|
next: () => {
|
|
this.applying = false;
|
|
this.load();
|
|
this.loadLocks();
|
|
this.loadSummary();
|
|
},
|
|
error: (err: any) => {
|
|
this.applying = false;
|
|
this.applyError = err?.error?.message || 'Failed to apply dividend to loan.';
|
|
}
|
|
});
|
|
}
|
|
}
|