392 lines
13 KiB
TypeScript
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.';
|
|
}
|
|
});
|
|
}
|
|
}
|