done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
@@ -9,17 +9,21 @@
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';
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).
* 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',
@@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service';
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
private claimRequestService = inject(DividendClaimRequestService);
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;
@@ -56,6 +73,21 @@ export class DividendDashboardComponent implements OnInit {
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;
@@ -70,164 +102,42 @@ export class DividendDashboardComponent implements OnInit {
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.';
/**
* 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 ?? ''
}
});
}
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 {
@@ -253,6 +163,33 @@ export class DividendDashboardComponent implements OnInit {
});
}
/**
* 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';
}
@@ -290,11 +227,16 @@ export class DividendDashboardComponent implements OnInit {
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
/**
* 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.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
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. */