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
@@ -34,6 +34,12 @@ export class DividendClaimRequestService {
ddBeneficiaryName: string;
ddPayableAtBranch: string;
ddPayableAtCity: string;
/**
* The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical
* form — without it the server takes the next available one, and the number the clerk wrote
* on the counterfoil would not be the number in the books.
*/
leafletId?: number;
}
): Observable<any> {
return this.http.post('/clerk/dividend-claim-requests', {
@@ -41,7 +47,8 @@ export class DividendClaimRequestService {
ddApplyAmount: String(claim.ddApplyAmount),
ddBeneficiaryName: claim.ddBeneficiaryName,
ddPayableAtBranch: claim.ddPayableAtBranch,
ddPayableAtCity: claim.ddPayableAtCity
ddPayableAtCity: claim.ddPayableAtCity,
leafletId: claim.leafletId ?? null
});
}
@@ -39,4 +39,5 @@ export class DividendDashboardService {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -46,14 +46,69 @@
</div>
}
<div class="pg-card">
<!-- One list at a time. The three answer unrelated questions and a clerk is only ever in one of
them; stacked, the one they came for was usually below the fold. Counts ride on the tabs so a
list worth opening — a blocked member, a lock nearing forfeiture — is visible without opening
it. -->
<div class="dd-tabs" role="tablist">
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'declarations'"
[attr.aria-selected]="activeTab === 'declarations'"
(click)="activeTab = 'declarations'"
>
Declarations
<span class="dd-tab__count">{{ declarations.length }}</span>
</button>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'overdue'"
[attr.aria-selected]="activeTab === 'overdue'"
(click)="activeTab = 'overdue'"
>
Overdue EMI
<span class="dd-tab__count" [class.dd-tab__count--alert]="rows.length > 0">{{ rows.length }}</span>
</button>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'locks'"
[attr.aria-selected]="activeTab === 'locks'"
(click)="activeTab = 'locks'"
>
Lock History
<span class="dd-tab__count">{{ locks.length }}</span>
</button>
</div>
<div class="pg-card" [hidden]="activeTab !== 'declarations'">
<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 class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search product, period or status…"
[(ngModel)]="declarationSearch"
name="declarationSearch"
/>
@if (declarationSearch) {
<button type="button" class="dd-search__clear" (click)="declarationSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{
declarations.length === 1 ? '' : 's'
}}
</span>
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">Refresh</button>
</div>
<table class="pg-table">
<thead>
@@ -75,8 +130,12 @@
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else if (visibleDeclarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No declaration matches “{{ declarationSearch }}”.</td>
</tr>
} @else {
@for (d of declarations; track d.declarationId) {
@for (d of visibleDeclarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
@@ -95,12 +154,27 @@
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pg-card" [hidden]="activeTab !== 'overdue'">
<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 class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="overdueSearch"
name="overdueSearch"
/>
@if (overdueSearch) {
<button type="button" class="dd-search__clear" (click)="overdueSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }}
</span>
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</div>
<table class="pg-table">
<thead>
@@ -122,10 +196,19 @@
<tr>
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
</tr>
} @else if (visibleOverdueRows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No member matches “{{ overdueSearch }}”.</td>
</tr>
} @else {
@for (row of rows; track row.lockId) {
@for (row of visibleOverdueRows; track row.lockId) {
<tr>
<td>{{ row.clientName }}</td>
<td>
{{ row.clientName }}
@if (row.shareAccountNo) {
<div class="dd-account-no">{{ row.shareAccountNo }}</div>
}
</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
@@ -181,7 +264,7 @@
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pg-card" [hidden]="activeTab !== 'locks'">
<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">
@@ -196,9 +279,21 @@
</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 class="dd-search" style="margin-left: auto">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="lockSearch"
name="lockSearch"
/>
@if (lockSearch) {
<button type="button" class="dd-search__clear" (click)="lockSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
@@ -246,7 +341,7 @@
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th></th>
<th>Demand Draft</th>
</tr>
</thead>
<tbody>
@@ -256,12 +351,23 @@
</tr>
} @else if (locksLoaded && visibleLocks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
<td colspan="8" class="pg-empty">
@if (lockSearch) {
No dividend lock matches “{{ lockSearch }}”.
} @else {
No dividend locks match this filter.
}
</td>
</tr>
} @else {
@for (l of visibleLocks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
{{ l.clientName }}
@if (l.shareAccountNo) {
<div class="dd-account-no">{{ l.shareAccountNo }}</div>
}
</td>
<td>
@if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
@@ -279,170 +385,21 @@
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<!-- Once a draft has been raised the clerk needs its number, not another Claim button:
it is what goes on the counterfoil and what the member asks about when they return.
Until then, the button hands off to the DD inventory to raise one. -->
<td>
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
@if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
<span class="dd-dd-number">{{ l.claimDdNumber }}</span>
} @else if (l.claimStatus === 'PENDING') {
<span class="dd-badge dd-badge--pending">DD {{ l.claimDdNumber }} — awaiting approval</span>
} @else if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn dd-apply-confirm" (click)="claimAsDemandDraft(l)">
Claim by DD
</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' }}
@if (l.financialYear) {
<span> (FY {{ l.financialYear }} dividend)</span>
}
— issue as a Demand Draft
</div>
<!-- A dividend is collected only as a Demand Draft. Amount defaults to the full
unclaimed balance; anything left unclaimed stays on the lock for a later visit. -->
<label class="inv-form-field">
<span>DD Amount</span>
<input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
</label>
<label class="inv-form-field">
<span>Beneficiary Name</span>
<input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — Branch</span>
<input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — City</span>
<input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
</label>
<div class="dd-apply-panel-total">
Claiming: {{ claimDdAmount[l.lockId] || 0 | 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>
<!-- The draft just drawn. Approving refreshes the list, so the row it came from is gone by the
time the clerk looks — and the DD number is exactly what they need to write on the
counterfoil and hand over. -->
@if (lastIssuedDd) {
<div class="dd-issued-banner">
<div class="dd-issued-banner__main">
Demand Draft <strong>{{ lastIssuedDd.ddNumber }}</strong> issued to {{ lastIssuedDd.clientName }} for
{{ lastIssuedDd.amount | number: '1.2-2' }}
</div>
<button type="button" class="dd-issued-banner__close" (click)="lastIssuedDd = null" aria-label="Dismiss">
&times;
</button>
</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>
<!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
draft about to be drawn: for how much, in whose favour, payable where. -->
<th class="num">DD Amount</th>
<th>Beneficiary</th>
<th>Payable At</th>
<th class="num">Unclaimed Balance</th>
<th>Requested By</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="7" 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 class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
<td>{{ req.dd_beneficiary_name || '—' }}</td>
<td>
@if (req.dd_payable_at_branch || req.dd_payable_at_city) {
{{ req.dd_payable_at_branch }}
@if (req.dd_payable_at_branch && req.dd_payable_at_city) {
,
}
{{ req.dd_payable_at_city }}
} @else {
—
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>{{ req.clerk_name }}</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="7">
<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>
@@ -244,3 +244,160 @@
}
}
}
/* One toolbar per table: search on the left where the eye lands first, the row count next to it so
a narrowed table says how much it is hiding, and Refresh pushed to the far right. */
.dd-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
&__count {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.dd-search {
position: relative;
flex: 1 1 260px;
max-width: 360px;
&__input {
width: 100%;
padding: 7px 30px 7px 11px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #0f172a;
background: #fff;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
/* The browser's own clear affordance would sit under ours. */
&::-webkit-search-cancel-button {
appearance: none;
}
}
/* Only rendered while there is something to clear, so it never reads as a dead control. */
&__clear {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
font-size: 17px;
line-height: 1;
cursor: pointer;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
}
/* The share account number under the member's name — the thing a clerk matches against the
passbook in front of them, so it has to be readable but must not outweigh the name. */
.dd-account-no {
margin-top: 2px;
font-size: 11.5px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Horizontal tab strip: one list on screen at a time, chosen here.
Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions
a clerk has learned do not move. */
.dd-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding-bottom: 2px;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.dd-tab {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
strip would shift by 2px each time the selection moved. */
border-bottom: 2px solid transparent;
background: transparent;
font-size: 12.5px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
cursor: pointer;
&:hover {
color: #1e40af;
}
&--active {
color: #1e40af;
border-bottom-color: #2563eb;
}
}
.dd-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
/* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding —
coloured so it reads as something to open rather than a neutral tally. */
&--alert {
background: #fef3c7;
color: #92400e;
}
}
/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it
against the counterfoil can compare digit by digit. */
.dd-dd-number {
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
@@ -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. */