/** * 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 = {}; applyAmount: Record = {}; 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.'; } }); } }