feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="dr">
|
||||
<!-- Header -->
|
||||
<div class="dr-head">
|
||||
<div>
|
||||
<h2 class="dr-head-t"><fa-icon icon="clock"></fa-icon> Inactive, Dormant & DEAF Reports</h2>
|
||||
<div class="dr-head-s">
|
||||
Accounts with no customer-induced transaction, at each of the three marks RBI sets. Interest the bank posts does
|
||||
not reset the clock — only a deposit or withdrawal the customer makes.
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-head-a">
|
||||
<button class="dr-btn dr-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="exportExcel()" [disabled]="loading">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export all three
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="dr-alert">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>{{ errorMessage }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- The money that has left customers' accounts but not reached RBI -->
|
||||
@if (summary.pendingRemittanceCount > 0) {
|
||||
<div class="dr-alert">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>₹{{ summary.pendingRemittance | number: '1.0-0' }}</strong> from
|
||||
<strong>{{ summary.pendingRemittanceCount }}</strong> escheated
|
||||
{{ summary.pendingRemittanceCount === 1 ? 'account has' : 'accounts have' }} not been recorded as remitted to
|
||||
RBI's DEAF. The balance has already left the customer's account, so until the remittance is recorded the money
|
||||
sits with the bank against no fund.
|
||||
<button class="dr-link" (click)="showTab('ESCHEAT'); pendingRemittanceOnly = true">Show these accounts</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Entry: filters that apply to all three reports -->
|
||||
<div class="dr-entry">
|
||||
<div class="dr-entry-h"><fa-icon icon="filter"></fa-icon> Report criteria</div>
|
||||
<div class="dr-entry-b">
|
||||
<div class="dr-field">
|
||||
<label>As on</label>
|
||||
<!-- The idle period is worked out server-side against the business date, so this states the
|
||||
day the figures are for rather than offering to recompute them for another. -->
|
||||
<input class="dr-inp dr-inp--ro" type="date" [value]="today" disabled />
|
||||
</div>
|
||||
|
||||
<div class="dr-field">
|
||||
<label>Branch</label>
|
||||
<select class="dr-inp" [(ngModel)]="branchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="dr-field">
|
||||
<label>Minimum balance</label>
|
||||
<input class="dr-inp" type="number" [(ngModel)]="minBalance" placeholder="Any" min="0" />
|
||||
</div>
|
||||
|
||||
<div class="dr-field dr-field--wide">
|
||||
<label>Search</label>
|
||||
<input class="dr-inp" type="text" [(ngModel)]="search" placeholder="Customer name, ID or account number" />
|
||||
</div>
|
||||
|
||||
<div class="dr-field dr-field--btn">
|
||||
<button class="dr-btn dr-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The three reports, side by side -->
|
||||
<div class="dr-tabs">
|
||||
@for (t of tabs; track t) {
|
||||
<button
|
||||
class="dr-tab"
|
||||
[class]="'dr-tab dr-tab--' + stageMeta[t].tone"
|
||||
[class.dr-tab--on]="tab === t"
|
||||
(click)="showTab(t)"
|
||||
>
|
||||
<div class="dr-tab-h">
|
||||
<span class="dr-tab-l">{{ stageMeta[t].label }}</span>
|
||||
<span class="dr-tab-n">{{ countFor(t) }}</span>
|
||||
</div>
|
||||
<div class="dr-tab-d">After {{ stageMeta[t].afterDays }} days idle</div>
|
||||
<div class="dr-tab-e">{{ stageMeta[t].effect }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- What this stage means, and how an account comes back from it -->
|
||||
<div class="dr-context">
|
||||
<div class="dr-context-i">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ stageMeta[tab].label }}.</strong> {{ stageMeta[tab].effect }}
|
||||
<div class="dr-context-r"><strong>Revival:</strong> {{ stageMeta[tab].revival }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-context-t">
|
||||
<span class="dr-context-c">{{ visibleRows.length }}</span> accounts ·
|
||||
<span class="dr-context-c">₹{{ visibleBalance | number: '1.0-0' }}</span> total balance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<div class="dr-bar">
|
||||
<label class="dr-chk">
|
||||
<input type="checkbox" [(ngModel)]="pendingRemittanceOnly" /> Not yet remitted to RBI
|
||||
</label>
|
||||
<span class="dr-money">
|
||||
<span class="dr-money-i dr-money-i--bad">
|
||||
₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted
|
||||
</span>
|
||||
<span class="dr-money-i dr-money-i--ok"> ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund </span>
|
||||
@if (summary.claimedBackCount > 0) {
|
||||
<span class="dr-money-i"> ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back </span>
|
||||
}
|
||||
</span>
|
||||
<span class="dr-spacer"></span>
|
||||
@if (selected.size > 0) {
|
||||
<span class="dr-selinfo"> {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} </span>
|
||||
<button class="dr-btn dr-btn--slim" (click)="clearSelection()">Clear</button>
|
||||
}
|
||||
<button class="dr-btn dr-btn--primary" (click)="openRemit()" [disabled]="selected.size === 0">
|
||||
<fa-icon icon="check"></fa-icon> Record remittance to RBI
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Table -->
|
||||
@if (loading) {
|
||||
<div class="dr-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="dr-empty">No accounts at this stage match the criteria above.</div>
|
||||
} @else {
|
||||
<div class="dr-tablewrap">
|
||||
<table class="dr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<th class="dr-tick">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected"
|
||||
(change)="toggleSelectAll()"
|
||||
title="Select every account not yet remitted"
|
||||
/>
|
||||
</th>
|
||||
}
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th class="num">{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}</th>
|
||||
<th>Last Customer Txn</th>
|
||||
<th>Idle For</th>
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<th>Escheated On</th>
|
||||
<th>Remittance to RBI</th>
|
||||
} @else {
|
||||
<th>Next Stage</th>
|
||||
}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of visibleRows; track r.accountId) {
|
||||
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<td class="dr-tick">
|
||||
<!-- An already-remitted balance cannot be remitted again. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selected.has(r.accountId)"
|
||||
[disabled]="r.remittance === 'REMITTED'"
|
||||
(click)="toggleSelect(r, $event)"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
<td class="mono">{{ r.accountNo }}</td>
|
||||
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
<div class="dr-sub mono">{{ r.clientId }} · {{ r.mobile }}</div>
|
||||
</td>
|
||||
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
|
||||
<td class="dr-prod">{{ r.productName }}</td>
|
||||
|
||||
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
|
||||
|
||||
<td class="mono">{{ r.lastCustomerTxnDate }}</td>
|
||||
|
||||
<td>
|
||||
<span [class]="toneClass(r.stage)">{{ idleText(r.daysIdle) }}</span>
|
||||
</td>
|
||||
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<td class="mono">{{ r.escheatedOn || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="remittanceClass(r.remittance)">
|
||||
{{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }}
|
||||
</span>
|
||||
@if (r.remittedOn) {
|
||||
<div class="dr-sub mono">{{ r.remittedOn }}</div>
|
||||
}
|
||||
@if (r.deafReference) {
|
||||
<div class="dr-sub mono">{{ r.deafReference }}</div>
|
||||
}
|
||||
@if (r.refundedOn) {
|
||||
<div class="dr-claimed"><fa-icon icon="undo"></fa-icon> Claimed back {{ r.refundedOn }}</div>
|
||||
}
|
||||
</td>
|
||||
} @else {
|
||||
<td>
|
||||
<div class="dr-next">{{ r.nextStageLabel }}</div>
|
||||
<div class="dr-sub mono" [class.dr-sub--red]="isNearingEscheat(r)">
|
||||
{{ r.nextStageDate }}
|
||||
@if (isNearingEscheat(r)) {
|
||||
· {{ daysToNext(r) }} days
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
|
||||
<td class="dr-act">
|
||||
<button class="dr-mini" (click)="openClient(r, $event)">
|
||||
<fa-icon icon="user"></fa-icon> Customer
|
||||
</button>
|
||||
<!-- A remitted balance can still be claimed, so long as it has not been already. -->
|
||||
@if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) {
|
||||
<button
|
||||
class="dr-mini"
|
||||
title="The customer claimed this balance back — the bank paid them and recovers it from DEAF"
|
||||
(click)="openRefund(r, $event)"
|
||||
>
|
||||
<fa-icon icon="undo"></fa-icon> Claimed back
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Where the money in the escheat report has to go -->
|
||||
@if (tab === 'ESCHEAT') {
|
||||
<div class="dr-foot">
|
||||
<fa-icon icon="gavel"></fa-icon>
|
||||
<div>
|
||||
<strong>Depositor Education and Awareness Fund.</strong> A balance unclaimed for ten years is transferred to
|
||||
RBI's DEAF. The transfer itself is made outside this system, so an account shows as escheated here from the day
|
||||
the balance leaves it — the remittance column records that the money actually reached RBI, and stays "not
|
||||
remitted" until it does. A customer can still claim the amount at any time; the bank pays them and recovers it
|
||||
from the fund, which shows here as claimed back.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Record that money was sent to RBI -->
|
||||
@if (remitOpen) {
|
||||
<div class="dr-modal-bg" (click)="remitOpen = false">
|
||||
<div class="dr-modal" (click)="$event.stopPropagation()">
|
||||
<div class="dr-modal-h">
|
||||
<h3>Record remittance to RBI's DEAF</h3>
|
||||
<button class="dr-x" (click)="remitOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-b">
|
||||
<div class="dr-modal-sum">
|
||||
<div>
|
||||
<span class="dr-modal-n">{{ selected.size }}</span>
|
||||
<span class="dr-modal-l">accounts</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dr-modal-n">₹{{ selectedTotal | number: '1.0-0' }}</span>
|
||||
<span class="dr-modal-l">total remitted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dr-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records a transfer that has already been made to RBI. It does not move any money — it marks these
|
||||
balances as accounted for, so they stop showing as outstanding.
|
||||
</div>
|
||||
|
||||
<label class="dr-lbl">Date sent to RBI <span class="dr-req">*</span></label>
|
||||
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="remitDate" />
|
||||
|
||||
<label class="dr-lbl">RBI / DEAF reference</label>
|
||||
<input
|
||||
class="dr-inp dr-inp--full"
|
||||
type="text"
|
||||
[(ngModel)]="remitReference"
|
||||
placeholder="e.g. DEAF/2026/Q2/004913 — as printed on RBI's advice"
|
||||
/>
|
||||
|
||||
<label class="dr-lbl">Remarks</label>
|
||||
<textarea class="dr-ta" rows="3" [(ngModel)]="remitRemarks" placeholder="Optional"></textarea>
|
||||
|
||||
@if (remitError) {
|
||||
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ remitError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-f">
|
||||
<button class="dr-btn" (click)="remitOpen = false">Cancel</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="saveRemittance()" [disabled]="remitSaving">
|
||||
@if (remitSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record remittance
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Customer claimed a remitted balance back -->
|
||||
@if (refundOpen && refundRow) {
|
||||
<div class="dr-modal-bg" (click)="refundOpen = false">
|
||||
<div class="dr-modal dr-modal--sm" (click)="$event.stopPropagation()">
|
||||
<div class="dr-modal-h">
|
||||
<h3>Record a claim against DEAF</h3>
|
||||
<button class="dr-x" (click)="refundOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-b">
|
||||
<div class="dr-modal-who">
|
||||
<strong>{{ refundRow.clientName }}</strong>
|
||||
<span class="mono">{{ refundRow.accountNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-sum">
|
||||
<div>
|
||||
<span class="dr-modal-n">₹{{ refundRow.balance | number: '1.0-0' }}</span>
|
||||
<span class="dr-modal-l">claimed</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="dr-modal-n mono">{{ refundRow.remittedOn }}</span>
|
||||
<span class="dr-modal-l">remitted to RBI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dr-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
A depositor can claim an unclaimed balance at any time, however long it has been with the fund. The bank pays
|
||||
the customer and recovers the amount from RBI. This records that, so the money is no longer counted as sitting
|
||||
with the fund.
|
||||
</div>
|
||||
|
||||
<label class="dr-lbl">Date the customer was paid <span class="dr-req">*</span></label>
|
||||
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="refundDate" />
|
||||
|
||||
@if (refundError) {
|
||||
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ refundError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dr-modal-f">
|
||||
<button class="dr-btn" (click)="refundOpen = false">Cancel</button>
|
||||
<button class="dr-btn dr-btn--primary" (click)="saveRefund()" [disabled]="refundSaving">
|
||||
@if (refundSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record claim
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (toastVisible) {
|
||||
<div class="dr-toast">{{ toastMsg }}</div>
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--d-navy: #1a237e;
|
||||
--d-blue: #1d4ed8;
|
||||
--d-blue-l: #eff6ff;
|
||||
--d-green: #15803d;
|
||||
--d-green-l: #dcfce7;
|
||||
--d-amber: #b45309;
|
||||
--d-amber-l: #fef3c7;
|
||||
--d-red: #c0392b;
|
||||
--d-red-l: #fdecea;
|
||||
--d-crit: #7f1d1d;
|
||||
--d-crit-l: #fee2e2;
|
||||
--d-g50: #f8fafc;
|
||||
--d-g100: #f1f5f9;
|
||||
--d-g200: #e2e8f0;
|
||||
--d-g300: #cbd5e1;
|
||||
--d-g500: #64748b;
|
||||
--d-g700: #334155;
|
||||
--d-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--d-g900);
|
||||
background: var(--d-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--d-navy);
|
||||
}
|
||||
|
||||
.dr-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--d-g500);
|
||||
max-width: 780px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dr-head-a {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The unremitted-money warning: the one number on this screen that is a live exposure. */
|
||||
.dr-alert {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--d-red-l);
|
||||
border: 1px solid #f5b7b1;
|
||||
border-left: 4px solid var(--d-red);
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-red);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-link {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--d-red);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Entry / criteria ───────────────────────────────────────────────────── */
|
||||
|
||||
.dr-entry {
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dr-entry-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
background: var(--d-g100);
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-entry-b {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.dr-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&--wide {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
&--btn {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-inp {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dr-chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── The three reports, side by side ────────────────────────────────────── */
|
||||
|
||||
.dr-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dr-tab {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-top: 3px solid var(--d-g300);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.12s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-top-color: var(--d-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-top-color: var(--d-amber);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-top-color: var(--d-crit);
|
||||
}
|
||||
|
||||
/* The report on display stays obvious while its rows are being read. */
|
||||
&--on {
|
||||
background: var(--d-g100);
|
||||
box-shadow: inset 0 0 0 1px var(--d-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-tab-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dr-tab-l {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr-tab-n {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dr-tab-d {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-tab-e {
|
||||
margin-top: 5px;
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Context strip ──────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--d-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-context-i {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-context-r {
|
||||
margin-top: 3px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-context-t {
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-context-c {
|
||||
font-weight: 700;
|
||||
color: var(--d-g900);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--d-blue);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 6px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--d-blue);
|
||||
border-color: var(--d-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dr-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--d-blue);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-tablewrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--d-g100);
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--d-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--d-g50);
|
||||
}
|
||||
}
|
||||
|
||||
/* A dormant account inside a year of the DEAF mark is the one to act on now. */
|
||||
.dr-row--warn {
|
||||
background: var(--d-amber-l);
|
||||
|
||||
&:hover {
|
||||
background: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
margin-top: 2px;
|
||||
|
||||
&--red {
|
||||
color: var(--d-amber);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-prod {
|
||||
max-width: 220px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.dr-next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-ref {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.dr-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--info {
|
||||
background: var(--d-blue-l);
|
||||
color: var(--d-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--d-amber-l);
|
||||
color: var(--d-amber);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: var(--d-crit-l);
|
||||
color: var(--d-crit);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-rem {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
&--ok {
|
||||
background: var(--d-green-l);
|
||||
color: var(--d-green);
|
||||
}
|
||||
|
||||
/* Money out of the customer's account but not yet with RBI — never shown as settled. */
|
||||
&--pending {
|
||||
background: var(--d-red-l);
|
||||
color: var(--d-red);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--d-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dr-foot {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--d-g200);
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--d-g500);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Selection & remittance bar ─────────────────────────────────────────── */
|
||||
|
||||
.dr-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dr-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dr-selinfo {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-tick {
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* An already-remitted row cannot be picked again; the cursor should say so. */
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-row--sel {
|
||||
background: var(--d-blue-l);
|
||||
}
|
||||
|
||||
.dr-act {
|
||||
white-space: nowrap;
|
||||
|
||||
.dr-mini + .dr-mini {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dr-modal-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dr-modal {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
|
||||
|
||||
&--sm {
|
||||
max-width: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-modal-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 17px;
|
||||
border-bottom: 1px solid var(--d-g200);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--d-navy);
|
||||
}
|
||||
}
|
||||
|
||||
.dr-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--d-g500);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dr-modal-b {
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.dr-modal-f {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 17px;
|
||||
border-top: 1px solid var(--d-g200);
|
||||
}
|
||||
|
||||
.dr-modal-sum {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
}
|
||||
|
||||
.dr-modal-n {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dr-modal-l {
|
||||
font-size: 10.5px;
|
||||
color: var(--d-g500);
|
||||
}
|
||||
|
||||
.dr-modal-who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 11px;
|
||||
margin-bottom: 11px;
|
||||
border-bottom: 1px solid var(--d-g100);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.dr-modal-p {
|
||||
margin: 12px 0 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-modal-ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
/* Marking an account exempt is a decision on record, not a convenience. */
|
||||
.dr-modal-warn {
|
||||
margin: 12px 0 0;
|
||||
padding: 8px 11px;
|
||||
background: var(--d-amber-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-amber);
|
||||
}
|
||||
|
||||
.dr-lbl {
|
||||
display: block;
|
||||
margin: 12px 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g700);
|
||||
}
|
||||
|
||||
.dr-req {
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
.dr-inp--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dr-ta {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--d-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dr-note-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--d-blue-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--d-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--d-blue);
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-err {
|
||||
margin-top: 10px;
|
||||
padding: 7px 10px;
|
||||
background: var(--d-red-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
.dr-toast {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
background: var(--d-g900);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Where the escheated money currently stands, read at a glance above the table. */
|
||||
.dr-money {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.dr-money-i {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--d-g500);
|
||||
|
||||
&--bad {
|
||||
color: var(--d-red);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
color: var(--d-green);
|
||||
}
|
||||
}
|
||||
|
||||
/* A balance the customer has taken back is no longer with the fund. */
|
||||
.dr-claimed {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--d-blue);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
DORMANCY_POLICY,
|
||||
DORMANCY_STAGES,
|
||||
DormancyRow,
|
||||
DormancyStage,
|
||||
DormancySummary,
|
||||
daysBetween,
|
||||
dormancySummary,
|
||||
nextStage,
|
||||
sortByIdle,
|
||||
stageFromSubStatus
|
||||
} from './dormancy-report.compute';
|
||||
|
||||
/**
|
||||
* The three reports on accounts that have gone quiet.
|
||||
*
|
||||
* They are one screen rather than three because they are one progression: an account moves from
|
||||
* inactive to dormant to escheat as the same silence lengthens, and an officer reading one usually
|
||||
* wants to see what is coming behind it. The filters at the top therefore apply to all three, and
|
||||
* the tabs only choose which stage is on display.
|
||||
*
|
||||
* The escheat tab carries the figure that matters most — money taken out of customers' accounts
|
||||
* that has not yet reached RBI's DEAF. Nothing in this system performs that remittance, so without
|
||||
* a report on it the gap is invisible.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dormancy-report',
|
||||
templateUrl: './dormancy-report.component.html',
|
||||
styleUrls: ['./dormancy-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DormancyReportComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
rows: DormancyRow[] = [];
|
||||
today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
/** Which of the three reports is on display. */
|
||||
tab: DormancyStage = 'DORMANT';
|
||||
|
||||
/** Filters, shared by all three tabs. */
|
||||
branchCode = '';
|
||||
search = '';
|
||||
minBalance: number | null = null;
|
||||
pendingRemittanceOnly = false;
|
||||
|
||||
/**
|
||||
* Branch options come from the rows themselves rather than a fixed list: the server returns the
|
||||
* office name it holds, and a dropdown built from anywhere else would offer branches that match
|
||||
* nothing and omit ones that do.
|
||||
*/
|
||||
get branches(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.branchCode).filter((b) => !!b))].sort();
|
||||
}
|
||||
readonly policy = DORMANCY_POLICY;
|
||||
readonly stageMeta = DORMANCY_STAGES;
|
||||
readonly tabs: DormancyStage[] = [
|
||||
'INACTIVE',
|
||||
'DORMANT',
|
||||
'ESCHEAT'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the accounts the dormancy job has marked.
|
||||
*
|
||||
* The stage is taken from the sub-status the job wrote rather than recomputed from the idle
|
||||
* period. The sub-status is what actually restricts the account, so a report that worked it out
|
||||
* independently could disagree with the account's real state and send officers after the wrong
|
||||
* ones.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
this.clientsService.getDormantAccounts().subscribe({
|
||||
next: (accounts: any[]) => {
|
||||
this.rows = sortByIdle(
|
||||
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the dormancy reports.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recording a DEAF remittance ────────────────────────────────────────────
|
||||
|
||||
/** Accounts ticked on the escheat tab, by account id. */
|
||||
selected = new Set<number>();
|
||||
|
||||
remitOpen = false;
|
||||
remitDate = new Date().toISOString().slice(0, 10);
|
||||
remitReference = '';
|
||||
remitRemarks = '';
|
||||
remitSaving = false;
|
||||
remitError = '';
|
||||
|
||||
toggleSelect(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (this.selected.has(row.accountId)) {
|
||||
this.selected.delete(row.accountId);
|
||||
} else {
|
||||
this.selected.add(row.accountId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ticks every unremitted row on display, or clears them if all are already ticked. */
|
||||
toggleSelectAll(): void {
|
||||
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
|
||||
if (selectable.every((r) => this.selected.has(r.accountId))) {
|
||||
selectable.forEach((r) => this.selected.delete(r.accountId));
|
||||
} else {
|
||||
selectable.forEach((r) => this.selected.add(r.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the whole selection.
|
||||
*
|
||||
* A method rather than `selected.clear()` in the template — Angular's expression syntax does not
|
||||
* allow calling built-in methods on a Set.
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
}
|
||||
|
||||
get allSelected(): boolean {
|
||||
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
|
||||
return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId));
|
||||
}
|
||||
|
||||
get selectedTotal(): number {
|
||||
return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0);
|
||||
}
|
||||
|
||||
openRemit(): void {
|
||||
this.remitDate = new Date().toISOString().slice(0, 10);
|
||||
this.remitReference = '';
|
||||
this.remitRemarks = '';
|
||||
this.remitError = '';
|
||||
this.remitOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the remittance against every ticked account.
|
||||
*
|
||||
* The server skips anything not escheated or already recorded, so the result is reported back as
|
||||
* counts rather than assumed — a batch that silently recorded fewer rows than were ticked would
|
||||
* leave the officer believing money was accounted for when it was not.
|
||||
*/
|
||||
saveRemittance(): void {
|
||||
if (this.selected.size === 0) {
|
||||
this.remitError = 'Select at least one account.';
|
||||
return;
|
||||
}
|
||||
if (!this.remitDate) {
|
||||
this.remitError = 'Enter the date the money was sent to RBI.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.remitSaving = true;
|
||||
this.remitError = '';
|
||||
|
||||
this.clientsService
|
||||
.recordDeafRemittance({
|
||||
savingsAccountIds: [...this.selected],
|
||||
remittedOn: this.remitDate,
|
||||
deafReference: this.remitReference.trim() || undefined,
|
||||
remarks: this.remitRemarks.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.remitSaving = false;
|
||||
this.remitOpen = false;
|
||||
const recorded = res?.changes?.recorded ?? res?.recorded ?? this.selected.size;
|
||||
const skipped = res?.changes?.skipped ?? res?.skipped ?? 0;
|
||||
this.selected.clear();
|
||||
this.toast(
|
||||
skipped > 0
|
||||
? `${recorded} remittance(s) recorded, ${skipped} skipped — already recorded or not escheated.`
|
||||
: `${recorded} remittance(s) recorded.`
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.remitSaving = false;
|
||||
this.remitError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the remittance.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer claiming a remitted balance back ──────────────────────────────
|
||||
|
||||
refundOpen = false;
|
||||
refundRow: DormancyRow | null = null;
|
||||
refundDate = new Date().toISOString().slice(0, 10);
|
||||
refundSaving = false;
|
||||
refundError = '';
|
||||
|
||||
/**
|
||||
* Opens the claim dialog for a balance already sent to the fund.
|
||||
*
|
||||
* A customer can claim an unclaimed deposit at any time, however long it has been with DEAF. The
|
||||
* bank pays them and recovers the amount from RBI, so recording it here keeps the remitted total
|
||||
* honest rather than leaving money counted as still sitting in the fund.
|
||||
*/
|
||||
openRefund(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.refundRow = row;
|
||||
this.refundDate = new Date().toISOString().slice(0, 10);
|
||||
this.refundError = '';
|
||||
this.refundOpen = true;
|
||||
}
|
||||
|
||||
saveRefund(): void {
|
||||
if (!this.refundRow) {
|
||||
return;
|
||||
}
|
||||
if (!this.refundDate) {
|
||||
this.refundError = 'Enter the date the customer was paid.';
|
||||
return;
|
||||
}
|
||||
|
||||
const row = this.refundRow;
|
||||
this.refundSaving = true;
|
||||
this.refundError = '';
|
||||
|
||||
this.clientsService.refundDeafRemittance(row.accountId, this.refundDate).subscribe({
|
||||
next: () => {
|
||||
this.refundSaving = false;
|
||||
this.refundOpen = false;
|
||||
this.toast(`Claim recorded for ${row.clientName}.`);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.refundSaving = false;
|
||||
this.refundError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
clearTimeout(this.toastTimer);
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
|
||||
}
|
||||
|
||||
/** Maps one DormantAccountData row onto what the three reports render. */
|
||||
private mapAccount(a: any): DormancyRow | null {
|
||||
const stage = stageFromSubStatus(Number(a?.subStatus));
|
||||
if (!stage) {
|
||||
// Only the three dormancy states belong here; anything else is not on this ladder.
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10);
|
||||
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
|
||||
|
||||
return {
|
||||
accountId: a?.accountId,
|
||||
accountNo: a?.accountNo || '—',
|
||||
clientId: a?.clientId,
|
||||
clientName: a?.clientName || '—',
|
||||
branchCode: a?.officeName || '',
|
||||
productName: a?.productName || '—',
|
||||
balance: Number(a?.balance ?? 0),
|
||||
lastCustomerTxnDate: lastTxn,
|
||||
daysIdle: Number(a?.daysIdle ?? 0),
|
||||
stage,
|
||||
subStatus: Number(a?.subStatus ?? 0),
|
||||
nextStageDate: next.date,
|
||||
nextStageLabel: next.label,
|
||||
// An escheated account counts as remitted only when a remittance has actually been recorded
|
||||
// against it. Absence of that row is the whole point of the report: the money has left the
|
||||
// customer's account but is not yet accounted for as having reached RBI.
|
||||
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
|
||||
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined,
|
||||
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined,
|
||||
deafReference: a?.deafReference || undefined,
|
||||
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined,
|
||||
mobile: a?.mobileNo || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Rows on the tab in view, after the shared filters. */
|
||||
get visibleRows(): DormancyRow[] {
|
||||
return this.rowsForStage(this.tab);
|
||||
}
|
||||
|
||||
private rowsForStage(stage: DormancyStage): DormancyRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (r.stage !== stage) {
|
||||
return false;
|
||||
}
|
||||
if (this.branchCode && r.branchCode !== this.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (this.minBalance != null && r.balance < this.minBalance) {
|
||||
return false;
|
||||
}
|
||||
// Only meaningful on the escheat tab, where a row can be awaiting remittance.
|
||||
if (this.pendingRemittanceOnly && r.remittance !== 'PENDING') {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Tab counts respect the filters, so the badge matches what the tab will show. */
|
||||
countFor(stage: DormancyStage): number {
|
||||
return this.rowsForStage(stage).length;
|
||||
}
|
||||
|
||||
get summary(): DormancySummary {
|
||||
return dormancySummary(this.rows, this.today);
|
||||
}
|
||||
|
||||
get visibleBalance(): number {
|
||||
return this.visibleRows.reduce((s, r) => s + r.balance, 0);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.branchCode = '';
|
||||
this.search = '';
|
||||
this.minBalance = null;
|
||||
this.pendingRemittanceOnly = false;
|
||||
}
|
||||
|
||||
showTab(stage: DormancyStage): void {
|
||||
this.tab = stage;
|
||||
// The remittance filter only exists on the escheat tab; carrying it elsewhere would silently
|
||||
// empty a tab that has no remittance state at all.
|
||||
if (stage !== 'ESCHEAT') {
|
||||
this.pendingRemittanceOnly = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The server already returns the office by name, so there is nothing to look up. */
|
||||
branchName(office: string): string {
|
||||
return office || '—';
|
||||
}
|
||||
|
||||
toneClass(stage: DormancyStage): string {
|
||||
return `dr-badge dr-badge--${DORMANCY_STAGES[stage].tone}`;
|
||||
}
|
||||
|
||||
/** "3 years 2 months" reads better than a four-digit day count. */
|
||||
idleText(days: number): string {
|
||||
const years = Math.floor(days / 365);
|
||||
const months = Math.floor((days % 365) / 30);
|
||||
if (years === 0) {
|
||||
return `${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (months === 0) {
|
||||
return `${years} year${years === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${years}y ${months}m`;
|
||||
}
|
||||
|
||||
/** Days until the next mark, for the accounts approaching one. */
|
||||
daysToNext(row: DormancyRow): number | null {
|
||||
return row.nextStageDate ? daysBetween(this.today, row.nextStageDate) : null;
|
||||
}
|
||||
|
||||
/** Flags a dormant account within a year of the ten-year mark. */
|
||||
isNearingEscheat(row: DormancyRow): boolean {
|
||||
const days = this.daysToNext(row);
|
||||
return row.stage === 'DORMANT' && days !== null && days <= 365;
|
||||
}
|
||||
|
||||
remittanceClass(state?: string): string {
|
||||
return state === 'REMITTED' ? 'dr-rem dr-rem--ok' : 'dr-rem dr-rem--pending';
|
||||
}
|
||||
|
||||
openClient(row: DormancyRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exports all three reports, not just the tab in view.
|
||||
*
|
||||
* They are read together — an auditor asking about escheat invariably asks what is behind it —
|
||||
* and a workbook holding one of the three would only have to be produced three times.
|
||||
*/
|
||||
exportExcel(): void {
|
||||
const stamp = `As on ${this.today}${this.branchCode ? ` · ${this.branchName(this.branchCode)}` : ' · All branches'}`;
|
||||
|
||||
exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Inactive',
|
||||
title: `Inactive Accounts — idle ${this.policy.inactiveDays}+ days`,
|
||||
subtitle: stamp,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Balance',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Becomes Dormant On'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
30,
|
||||
14,
|
||||
18,
|
||||
12,
|
||||
20
|
||||
],
|
||||
rows: this.rowsForStage('INACTIVE').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.productName,
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.nextStageDate
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Dormant',
|
||||
title: `Dormant / Inoperative Accounts — idle ${this.policy.dormantDays}+ days`,
|
||||
subtitle: stamp,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Balance',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Due to DEAF On',
|
||||
'Nearing DEAF'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
30,
|
||||
14,
|
||||
18,
|
||||
12,
|
||||
18,
|
||||
14
|
||||
],
|
||||
rows: this.rowsForStage('DORMANT').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.productName,
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.nextStageDate,
|
||||
this.isNearingEscheat(r) ? 'Within 1 year' : ''
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Escheat & DEAF',
|
||||
title: `Escheated Accounts — idle ${this.policy.escheatDays}+ days`,
|
||||
subtitle: `${stamp} · Not yet remitted to RBI: ₹${this.summary.pendingRemittance.toLocaleString('en-IN')}`,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Branch',
|
||||
'Balance Escheated',
|
||||
'Last Customer Txn',
|
||||
'Idle',
|
||||
'Escheated On',
|
||||
'Remittance to RBI',
|
||||
'Remitted On',
|
||||
'DEAF Reference',
|
||||
'Claimed Back On'
|
||||
],
|
||||
widths: [
|
||||
18,
|
||||
28,
|
||||
12,
|
||||
22,
|
||||
18,
|
||||
18,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
24,
|
||||
16
|
||||
],
|
||||
rows: this.rowsForStage('ESCHEAT').map((r) => [
|
||||
r.accountNo,
|
||||
r.clientName,
|
||||
String(r.clientId),
|
||||
this.branchName(r.branchCode),
|
||||
r.balance,
|
||||
r.lastCustomerTxnDate,
|
||||
this.idleText(r.daysIdle),
|
||||
r.escheatedOn || '',
|
||||
r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted',
|
||||
r.remittedOn || '',
|
||||
r.deafReference || '',
|
||||
r.refundedOn || ''
|
||||
])
|
||||
}
|
||||
],
|
||||
`dormancy-reports-${this.today}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accounts that have gone quiet, and what has happened to them.
|
||||
*
|
||||
* RBI counts the silence from the last *customer-induced* transaction — a deposit or a withdrawal
|
||||
* the customer made. Interest the bank posts and charges it recovers are the bank operating the
|
||||
* account, not the customer using it, so they do not reset the clock. An account crosses three
|
||||
* marks: inactive at a year, inoperative at two, and due to the Depositor Education and Awareness
|
||||
* Fund at ten.
|
||||
*
|
||||
* The escheat stage is the one that needs watching. Sending the balance to DEAF is a transfer to
|
||||
* RBI that happens outside this system, so an account can sit closed here with its money not yet
|
||||
* remitted. That gap is what the third report exists to show.
|
||||
*/
|
||||
|
||||
/** The three marks an idle account passes. */
|
||||
export type DormancyStage = 'INACTIVE' | 'DORMANT' | 'ESCHEAT';
|
||||
|
||||
/** Sub-status codes as stored on the savings account. */
|
||||
export const SUB_STATUS = {
|
||||
NONE: 0,
|
||||
INACTIVE: 100,
|
||||
DORMANT: 200,
|
||||
ESCHEAT: 300
|
||||
};
|
||||
|
||||
/** RBI periods, in days from the last customer transaction. */
|
||||
export const DORMANCY_POLICY = {
|
||||
inactiveDays: 365,
|
||||
dormantDays: 730,
|
||||
escheatDays: 3650
|
||||
};
|
||||
|
||||
export interface DormancyStageMeta {
|
||||
stage: DormancyStage;
|
||||
label: string;
|
||||
tone: 'info' | 'warn' | 'critical';
|
||||
afterDays: number;
|
||||
/** What the customer can still do. */
|
||||
effect: string;
|
||||
/** How the account comes back, in the officer's words. */
|
||||
revival: string;
|
||||
}
|
||||
|
||||
export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
|
||||
INACTIVE: {
|
||||
stage: 'INACTIVE',
|
||||
label: 'Inactive',
|
||||
tone: 'info',
|
||||
afterDays: DORMANCY_POLICY.inactiveDays,
|
||||
effect: 'Nothing is restricted. The account is flagged internally so the branch can reach out.',
|
||||
revival: 'Any customer deposit or withdrawal clears the flag at once.'
|
||||
},
|
||||
DORMANT: {
|
||||
stage: 'DORMANT',
|
||||
label: 'Dormant / Inoperative',
|
||||
tone: 'warn',
|
||||
afterDays: DORMANCY_POLICY.dormantDays,
|
||||
effect: 'Debits are stopped. Credits still land, so pension and benefit transfers are unaffected.',
|
||||
revival: 'The customer completes KYC at the branch and makes one transaction — no reactivation charge.'
|
||||
},
|
||||
ESCHEAT: {
|
||||
stage: 'ESCHEAT',
|
||||
label: 'Escheat — due to DEAF',
|
||||
tone: 'critical',
|
||||
afterDays: DORMANCY_POLICY.escheatDays,
|
||||
effect: 'The account is closed and the balance is taken out, to be remitted to RBI’s DEAF.',
|
||||
revival: 'The customer can still claim the money from the bank, which then recovers it from DEAF.'
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the balance taken out at escheat has actually reached RBI. */
|
||||
export type DeafRemittanceState = 'PENDING' | 'REMITTED';
|
||||
|
||||
export interface DormancyRow {
|
||||
accountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
branchCode: string;
|
||||
productName: string;
|
||||
balance: number;
|
||||
/** Last transaction the customer themselves made. */
|
||||
lastCustomerTxnDate: string;
|
||||
daysIdle: number;
|
||||
stage: DormancyStage;
|
||||
/** Sub-status currently on the account. */
|
||||
subStatus: number;
|
||||
/** The day the next mark is crossed; blank once escheated. */
|
||||
nextStageDate: string;
|
||||
nextStageLabel: string;
|
||||
/** Escheat only — whether the money has gone to RBI. */
|
||||
remittance?: DeafRemittanceState;
|
||||
/** Escheat only — the day the balance was taken out of the account. */
|
||||
escheatedOn?: string;
|
||||
/** Escheat only — the day it was remitted, once it has been. */
|
||||
remittedOn?: string;
|
||||
/** Escheat only — RBI's reference for the remittance. */
|
||||
deafReference?: string;
|
||||
/** Escheat only — set if the customer claimed the balance back after it went to the fund. */
|
||||
refundedOn?: string;
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(earlier: string, later: string): number {
|
||||
const a = new Date(`${earlier}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${later}T00:00:00Z`).getTime();
|
||||
return Math.round((b - a) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage an account is at, from the sub-status the dormancy job wrote on it.
|
||||
*
|
||||
* Preferred over deriving it from the idle period, because the sub-status is what actually
|
||||
* restricts the account. Anything outside the three dormancy codes — a manual block, an active
|
||||
* account — is not on this ladder and returns null.
|
||||
*/
|
||||
export function stageFromSubStatus(subStatus: number): DormancyStage | null {
|
||||
if (subStatus === SUB_STATUS.INACTIVE) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
if (subStatus === SUB_STATUS.DORMANT) {
|
||||
return 'DORMANT';
|
||||
}
|
||||
if (subStatus === SUB_STATUS.ESCHEAT) {
|
||||
return 'ESCHEAT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Which mark an account has passed, from how long it has been idle. */
|
||||
export function dormancyStage(daysIdle: number): DormancyStage | null {
|
||||
if (daysIdle >= DORMANCY_POLICY.escheatDays) {
|
||||
return 'ESCHEAT';
|
||||
}
|
||||
if (daysIdle >= DORMANCY_POLICY.dormantDays) {
|
||||
return 'DORMANT';
|
||||
}
|
||||
if (daysIdle >= DORMANCY_POLICY.inactiveDays) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The next mark and the day it falls due. */
|
||||
export function nextStage(lastTxnDate: string, stage: DormancyStage): { date: string; label: string } {
|
||||
if (stage === 'INACTIVE') {
|
||||
return { date: addDays(lastTxnDate, DORMANCY_POLICY.dormantDays), label: 'Becomes dormant' };
|
||||
}
|
||||
if (stage === 'DORMANT') {
|
||||
return { date: addDays(lastTxnDate, DORMANCY_POLICY.escheatDays), label: 'Balance due to DEAF' };
|
||||
}
|
||||
// Escheat is the last mark — nothing further happens to the account on its own.
|
||||
return { date: '', label: 'Awaiting DEAF remittance' };
|
||||
}
|
||||
|
||||
export interface DormancySummary {
|
||||
inactive: number;
|
||||
dormant: number;
|
||||
escheat: number;
|
||||
inactiveBalance: number;
|
||||
dormantBalance: number;
|
||||
escheatBalance: number;
|
||||
/** Escheated money not yet sent to RBI — the figure that must not be left to drift. */
|
||||
pendingRemittance: number;
|
||||
pendingRemittanceCount: number;
|
||||
/** Balances that reached the fund and are still with it. */
|
||||
remittedBalance: number;
|
||||
/** Balances since claimed back by the customer and recovered from the fund. */
|
||||
claimedBackBalance: number;
|
||||
claimedBackCount: number;
|
||||
/** Dormant accounts approaching the ten-year mark within a year. */
|
||||
nearingEscheat: number;
|
||||
}
|
||||
|
||||
export function dormancySummary(rows: DormancyRow[], today: string): DormancySummary {
|
||||
const inactive = rows.filter((r) => r.stage === 'INACTIVE');
|
||||
const dormant = rows.filter((r) => r.stage === 'DORMANT');
|
||||
const escheat = rows.filter((r) => r.stage === 'ESCHEAT');
|
||||
const pending = escheat.filter((r) => r.remittance === 'PENDING');
|
||||
const claimedBack = escheat.filter((r) => r.refundedOn);
|
||||
// A balance the customer has since claimed back is no longer with the fund, so it is counted
|
||||
// separately rather than left in the remitted total.
|
||||
const stillWithFund = escheat.filter((r) => r.remittance === 'REMITTED' && !r.refundedOn);
|
||||
const sum = (list: DormancyRow[]) => list.reduce((s, r) => s + r.balance, 0);
|
||||
|
||||
return {
|
||||
inactive: inactive.length,
|
||||
dormant: dormant.length,
|
||||
escheat: escheat.length,
|
||||
inactiveBalance: sum(inactive),
|
||||
dormantBalance: sum(dormant),
|
||||
escheatBalance: sum(escheat),
|
||||
pendingRemittance: sum(pending),
|
||||
pendingRemittanceCount: pending.length,
|
||||
remittedBalance: sum(stillWithFund),
|
||||
claimedBackBalance: sum(claimedBack),
|
||||
claimedBackCount: claimedBack.length,
|
||||
nearingEscheat: dormant.filter((r) => r.nextStageDate && daysBetween(today, r.nextStageDate) <= 365).length
|
||||
};
|
||||
}
|
||||
|
||||
/** Idle longest first — the accounts closest to the next mark need attention first. */
|
||||
export function sortByIdle(rows: DormancyRow[]): DormancyRow[] {
|
||||
return [...rows].sort((a, b) => b.daysIdle - a.daysIdle || b.balance - a.balance);
|
||||
}
|
||||
Reference in New Issue
Block a user