feat(shares): real-profit driven dividend UI, Dividend Dashboard, and member claim workflow

Frontend counterpart to the backend dividend rework:
- Create Dividend screen auto-computes the amount from real bank profit x officer
  percentage, blocks overlapping declaration periods, and adds a Total Dividend Paid
  summary card.
- View Dividend page modernized and reusable as an embedded popup from the new
  cross-product "Dividend Requests" approval-center tab.
- New consolidated Dividend Dashboard: summary stats, every declaration and its
  status, locked-dividend-vs-overdue-EMI matching with a proactive apply-to-loan
  action, full 3-year lock history, and a maker-checker claim panel letting a clerk
  split a member's unclaimed dividend across an overdue loan, savings credit, or
  cash for a manager to approve.
- Menu/routing consolidated under Share > Dividend Management; removed the
  now-unreachable Failed Dividend Payouts page and its home-dashboard alert once the
  backend stopped needing a retry path for stuck payouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:43:18 +05:30
co-authored by Claude Sonnet 5
parent 01e86173c1
commit c34f6a0131
24 changed files with 2004 additions and 39 deletions
@@ -0,0 +1,66 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
* against a still-locked dividend on the member's behalf, a Bank Manager approves (only possible
* when the client has no overdue loan) or rejects it.
*/
@Injectable({ providedIn: 'root' })
export class DividendClaimRequestService {
private http = inject(HttpClient);
submit(
lockId: number,
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
}
): Observable<any> {
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason });
}
}
@@ -0,0 +1,42 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service for the Bank Manager's Dividend Dashboard — members with a locked (3-year)
* share dividend who also have an overdue loan EMI, with an action to apply the locked dividend
* towards that EMI.
*/
@Injectable({ providedIn: 'root' })
export class DividendDashboardService {
private http = inject(HttpClient);
getDashboard(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard');
}
applyToLoan(lockId: number, loanId: number, amount: number): Observable<any> {
return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) });
}
getSummary(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/summary');
}
getDeclarations(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/declarations');
}
getLocks(status?: string): Observable<any> {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -0,0 +1,405 @@
<!--
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/.
-->
@if (summary) {
<div class="dd-summary-grid">
<div class="dd-stat-card">
<div class="dd-stat-label">Declarations</div>
<div class="dd-stat-value">{{ summary.totalDeclarations }}</div>
<div class="dd-stat-sub">{{ summary.pendingApprovalDeclarations }} pending approval</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Members Affected</div>
<div class="dd-stat-value">{{ summary.membersAffected }}</div>
<div class="dd-stat-sub">{{ summary.totalMemberDividends }} member-dividends total</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Total Amount</div>
<div class="dd-stat-value">{{ summary.totalAmount | number: '1.2-2' }}</div>
<div class="dd-stat-sub">across all declared dividends</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Currently Locked</div>
<div class="dd-stat-value">{{ summary.lockedCount }}</div>
<div class="dd-stat-sub">{{ summary.lockedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Applied to Loans</div>
<div class="dd-stat-value">{{ summary.appliedToLoanCount }}</div>
<div class="dd-stat-sub">{{ summary.appliedToLoanAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Claimed by Member</div>
<div class="dd-stat-value">{{ summary.claimedCount }}</div>
<div class="dd-stat-sub">{{ summary.claimedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card dd-stat-card--forfeited">
<div class="dd-stat-label">Forfeited to Profit</div>
<div class="dd-stat-value">{{ summary.forfeitedCount }}</div>
<div class="dd-stat-sub">{{ summary.forfeitedAmount | number: '1.2-2' }}</div>
</div>
</div>
}
<div class="pg-card">
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Period</th>
<th class="num">Declared Amount</th>
<th class="num">Members</th>
<th>Status</th>
<th>Approved On</th>
</tr>
</thead>
<tbody>
@if (declarationsLoading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (declarationsLoaded && declarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else {
@for (d of declarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
<td class="num">{{ d.declaredAmount | number: '1.2-2' }}</td>
<td class="num">{{ d.totalMembers }}</td>
<td>
<span class="dd-badge" [ngClass]="declarationStatusClass(d.status)">{{
declarationStatusLabel(d.status)
}}</span>
</td>
<td>{{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th class="num">Locked Dividend</th>
<th>Locked Date</th>
<th>Release Due</th>
<th class="num">Total Overdue EMI</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
</tr>
} @else {
@for (row of rows; track row.lockId) {
<tr>
<td>{{ row.clientName }}</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
<td class="num">{{ totalOverdue(row) | number: '1.2-2' }}</td>
<td>
<button type="button" class="pg-btn" (click)="toggleApply(row)">
{{ expandedLockId === row.lockId ? 'Cancel' : 'Apply to Loan' }}
</button>
</td>
</tr>
@if (expandedLockId === row.lockId) {
<tr class="dd-apply-row">
<td colspan="6">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Loan</span>
<select [(ngModel)]="selectedLoanId[row.lockId]" (change)="onLoanChange(row)">
@for (loan of row.overdueLoans; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Amount to Apply (max {{ maxAmountFor(row) | number: '1.2-2' }})</span>
<input
type="number"
[(ngModel)]="applyAmount[row.lockId]"
[max]="maxAmountFor(row)"
min="0.01"
step="0.01"
/>
</label>
@if (applyError) {
<div class="dd-apply-error">{{ applyError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="applying"
(click)="submitApply(row)"
>
{{ applying ? 'Applying…' : 'Confirm' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
<div class="dd-filter-group">
@for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockStatusFilter === opt"
(click)="setLockStatusFilter(opt)"
>
{{ opt === 'ALL' ? 'All' : (opt | titlecase) }}
</button>
}
</div>
<label class="inv-form-field" style="align-self: end; margin-left: auto">
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th></th>
</tr>
</thead>
<tbody>
@if (locksLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && locks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of locks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
<td>{{ l.releaseDueDate }}</td>
<td>
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<td>
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
</button>
}
</td>
</tr>
@if (expandedClaimLockId === l.lockId) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
</div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field">
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimSubmitting"
(click)="submitClaim(l)"
>
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td>
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
</button>
@if (claimActionError[req.id]) {
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
}
</td>
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
<input
type="text"
[(ngModel)]="rejectReason[req.id]"
placeholder="Why is this claim being rejected?"
/>
</label>
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimActioningId === req.id"
(click)="rejectClaim(req)"
>
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
@@ -0,0 +1,171 @@
/**
* 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/.
*/
.dd-apply-row td {
background: #f8fafc;
padding: 0;
}
.dd-apply-panel {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
padding: 14px 16px;
.inv-form-field {
min-width: 220px;
}
}
.dd-apply-error {
color: #b42318;
font-size: 12.5px;
font-weight: 600;
}
.dd-apply-panel--wrap {
align-items: flex-end;
}
.dd-apply-panel-title {
width: 100%;
font-size: 12.5px;
font-weight: 700;
color: #475569;
}
.dd-apply-panel-total {
font-size: 13px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.dd-apply-confirm {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:disabled {
opacity: 0.6;
}
}
.dd-retry-error {
margin-top: 4px;
color: #b42318;
font-size: 12px;
font-weight: 600;
max-width: 240px;
}
.dd-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.dd-badge--pending {
background: #fef3c7;
color: #92400e;
}
.pg-empty-inline {
color: #94a3b8;
font-size: 12.5px;
font-style: italic;
}
.dd-badge--failed {
background: #fee2e2;
color: #b42318;
}
.dd-badge--posted {
background: #dcfce7;
color: #166534;
}
.dd-badge--partial {
background: #dbeafe;
color: #1e40af;
}
/* ─── Summary stat cards ─── */
.dd-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.dd-stat-card {
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 10px;
padding: 12px 14px;
border-left: 3px solid #94a3b8;
&--forfeited {
border-left-color: #7c3aed;
}
}
.dd-stat-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.dd-stat-value {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dd-stat-sub {
font-size: 11.5px;
color: #94a3b8;
margin-top: 2px;
}
/* ─── Lock status filter buttons ─── */
.dd-filter-group {
display: flex;
gap: 6px;
}
.dd-filter-btn {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
font-size: 12.5px;
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #94a3b8;
}
&--active {
background: #1e293b;
border-color: #1e293b;
color: #fff;
}
}
@@ -0,0 +1,348 @@
/**
* 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;
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
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;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Number(this.claimCashAmount[lock.lockId] || 0)
);
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
const cashAmount = Number(this.claimCashAmount[lock.lockId] || 0);
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined
})
.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: () => {
this.claimActioningId = null;
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.locksLoading = false;
this.locksLoaded = true;
});
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
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.';
}
});
}
}