333 lines
11 KiB
TypeScript
333 lines
11 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 { Router } from '@angular/router';
|
|
import { catchError } from 'rxjs/operators';
|
|
import { of } from 'rxjs';
|
|
|
|
import { DividendDashboardService } from '../dividend-dashboard.service';
|
|
|
|
/**
|
|
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock
|
|
* history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
|
* proactive "Apply to Loan" action).
|
|
*
|
|
* Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a
|
|
* Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every
|
|
* other draft, so there is no separate claim queue here — the lock history shows the DD number once
|
|
* it has been drawn.
|
|
*/
|
|
@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 router = inject(Router);
|
|
|
|
/**
|
|
* Which list the clerk is looking at.
|
|
*
|
|
* The three lists answer unrelated questions — what has been declared, who is blocked by an
|
|
* overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one
|
|
* page they made the screen long enough that the section a clerk came for was usually below the
|
|
* fold, so they are tabbed instead.
|
|
*
|
|
* Every list is still loaded on init rather than on first view: the counts in the tab strip have
|
|
* to be right before a tab is opened, or the strip would be advertising numbers it does not know.
|
|
*/
|
|
activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations';
|
|
|
|
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;
|
|
|
|
/**
|
|
* Free-text search, one box per table.
|
|
*
|
|
* Kept separate rather than shared because the three tables answer different questions and a
|
|
* clerk is usually in one of them: narrowing the lock history to a member should not blank out
|
|
* the declarations table above it, which has nothing to do with that member.
|
|
*
|
|
* Filtering is done here rather than server-side because every row is already loaded — these are
|
|
* a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower
|
|
* than the filter itself and would blank the table while it ran.
|
|
*/
|
|
declarationSearch = '';
|
|
overdueSearch = '';
|
|
lockSearch = '';
|
|
|
|
/** 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';
|
|
|
|
ngOnInit(): void {
|
|
this.loadSummary();
|
|
this.loadDeclarations();
|
|
this.load();
|
|
this.loadLocks();
|
|
}
|
|
|
|
/**
|
|
* Takes the clerk to the DD inventory to draw the draft this dividend is paid out as.
|
|
*
|
|
* A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue
|
|
* one — and the place a draft is issued is the leaflet inventory, where the clerk can see which
|
|
* numbered leaflet they are about to commit. Gathering the draft's details on this dashboard
|
|
* instead meant filling a second, thinner version of the DD form and never seeing the DD number
|
|
* until after approval.
|
|
*
|
|
* The dividend travels in the query string rather than in a service, so a reloaded or
|
|
* bookmarked page still knows which lock it is drawing against instead of silently issuing an
|
|
* ordinary counter draft.
|
|
*/
|
|
claimAsDemandDraft(lock: any): void {
|
|
this.router.navigate(
|
|
[
|
|
'/sponsor-dd',
|
|
'inventory'
|
|
],
|
|
{
|
|
queryParams: {
|
|
dividendLockId: lock.lockId,
|
|
dividendAmount: Number(lock.amount),
|
|
dividendBeneficiary: lock.clientName ?? '',
|
|
dividendFinancialYear: lock.financialYear ?? ''
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether any of a row's searchable fields contains the term.
|
|
*
|
|
* Substring rather than prefix matching, because a clerk reading an account number off a passbook
|
|
* often types the distinctive tail of it rather than the branch prefix every account shares. Null
|
|
* fields are skipped — a lock with no share account still has to be findable by member name.
|
|
*/
|
|
private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean {
|
|
const needle = term.trim().toLowerCase();
|
|
if (!needle) {
|
|
return true;
|
|
}
|
|
return fields.some((field) => field != null && String(field).toLowerCase().includes(needle));
|
|
}
|
|
|
|
/** Declarations the table renders. Product-level rows, so there is no member to search on. */
|
|
get visibleDeclarations(): any[] {
|
|
return this.declarations.filter((d) =>
|
|
this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status)
|
|
);
|
|
}
|
|
|
|
/** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */
|
|
get visibleOverdueRows(): any[] {
|
|
return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo));
|
|
}
|
|
|
|
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 and the
|
|
* search here. All three narrow together, so a clerk can hold a year and still search within it.
|
|
*/
|
|
get visibleLocks(): any[] {
|
|
return this.locks.filter(
|
|
(l) =>
|
|
(this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) &&
|
|
this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo)
|
|
);
|
|
}
|
|
|
|
/** 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.';
|
|
}
|
|
});
|
|
}
|
|
}
|