Files
cbsnexora/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
T

392 lines
13 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;
/** 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<number, number | null> = {};
claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
/**
* 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<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;
// 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.';
}
});
}
}