/** * 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; /** Every lock the status filter returned, before the year filter narrows it. */ locks: any[] = []; locksLoading = false; locksLoaded = false; lockStatusFilter = 'ALL'; /** * Financial years that actually have locks, newest first — derived from the data rather than * listed anywhere, so a new declaration adds its year on its own and a year whose locks are all * gone stops being offered. No maintenance as years roll over. */ lockYears: string[] = []; lockYearFilter = 'ALL'; expandedClaimLockId: number | null = null; /** * A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and * loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn * in whose favour, and payable where. */ claimDdAmount: Record = {}; claimDdBeneficiary: Record = {}; claimDdPayableAtBranch: Record = {}; claimDdPayableAtCity: Record = {}; claimSubmitting = false; claimError: string | null = null; claimRequests: any[] = []; claimRequestsLoading = false; claimRequestsLoaded = false; claimActioningId: number | null = null; claimActionError: Record = {}; /** * The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number * after the row it came from has left the pending list — it is what goes on the counterfoil and * into the member's hand, and there is nowhere else on this screen to find it. */ lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null; 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; // Defaults to the whole unclaimed balance and the member's own name, which is what a member // asks for almost every time; the clerk overrides only when the member wants otherwise. this.claimDdAmount[lock.lockId] = Number(lock.amount); this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? ''; this.claimDdPayableAtBranch[lock.lockId] = ''; this.claimDdPayableAtCity[lock.lockId] = ''; } submitClaim(lock: any): void { const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0); const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim(); const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim(); const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim(); if (ddAmount <= 0) { this.claimError = 'Enter a DD amount greater than zero.'; return; } if (ddAmount > Number(lock.amount)) { this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`; return; } if (!beneficiary) { this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.'; return; } if (!payableAtBranch || !payableAtCity) { this.claimError = 'Enter both the branch and city the Demand Draft is payable at.'; return; } this.claimSubmitting = true; this.claimError = null; this.claimRequestService .submit(lock.lockId, { ddApplyAmount: ddAmount, ddBeneficiaryName: beneficiary, ddPayableAtBranch: payableAtBranch, ddPayableAtCity: payableAtCity }) .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: (res: any) => { this.claimActioningId = null; // The draft number is the one thing the clerk needs off this action — it is what they write // on the counterfoil and hand to the member. Approving refreshes the list, so the row this // came from disappears; without surfacing it here the number would be lost. if (res?.ddNumber) { this.lastIssuedDd = { ddNumber: res.ddNumber, clientName: req.client_name, amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0 }; } 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.rebuildLockYears(); this.locksLoading = false; this.locksLoaded = true; }); } /** * Rebuilds the year chips from whatever locks came back, newest first. * * A year the current status filter has no locks for disappears from the chips, so the selected * year is reset to ALL when that happens — otherwise the table would show nothing with no * indication why. */ private rebuildLockYears(): void { this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) => b.localeCompare(a) ); if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) { this.lockYearFilter = 'ALL'; } } /** Locks the table actually renders — the status filter is applied server-side, the year here. */ get visibleLocks(): any[] { return this.lockYearFilter === 'ALL' ? this.locks : this.locks.filter((l) => l.financialYear === this.lockYearFilter); } /** Total of what is on screen, so a year's worth of dividend can be read off directly. */ get visibleLocksTotal(): number { return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); } setLockStatusFilter(status: string): void { this.lockStatusFilter = status; this.loadLocks(); } setLockYearFilter(year: string): void { this.lockYearFilter = year; } 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.'; } }); } }