/** * 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 = {}; applyAmount: Record = {}; 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 = {}; claimLoanId: Record = {}; claimLoanAmount: Record = {}; claimSavingsAmount: Record = {}; claimSavingsAccountOverride: Record = {}; claimCashAmount: Record = {}; claimTokNo: Record = {}; claimSubmitting = false; claimError: string | null = null; claimRequests: any[] = []; claimRequestsLoading = false; claimRequestsLoaded = false; claimActioningId: number | null = null; claimActionError: Record = {}; expandedRejectClaimId: number | null = null; rejectReason: Record = {}; 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.'; } }); } }