feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
<!--
|
||||
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="ke">
|
||||
<!-- Header -->
|
||||
<div class="ke-head">
|
||||
<div>
|
||||
<h2 class="ke-head-t"><fa-icon icon="id-card"></fa-icon> Expired KYC & Customer Outreach</h2>
|
||||
<div class="ke-head-s">
|
||||
Customers whose periodic re-KYC has lapsed: what each restriction stops, and the record of every attempt to
|
||||
reach them. A customer drops off this list the moment their re-KYC is completed.
|
||||
</div>
|
||||
</div>
|
||||
<button class="ke-btn ke-btn--slim" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="undo"></fa-icon> Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The rule the screen runs on -->
|
||||
<div class="ke-policy">
|
||||
<fa-icon icon="gavel"></fa-icon>
|
||||
<div>
|
||||
<strong>RBI Master Direction on KYC.</strong> An account is not closed or restricted merely because periodic
|
||||
re-KYC is pending. Nothing stops for the first <strong>{{ policy.graceDays }} days</strong> past the due date;
|
||||
debits stop after that, and the account is marked inoperative after
|
||||
<strong>{{ policy.inoperativeDays }} days</strong>. Restrictions are applied to the customer's savings accounts
|
||||
overnight and lift as soon as the re-KYC is completed. These credits are never held up:
|
||||
<span class="ke-allow-list">{{ alwaysAllowed.join(' · ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="ke-warnbar"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!-- Ladder summary; each card filters the worklist -->
|
||||
<div class="ke-stats">
|
||||
@for (key of stageKeys; track key) {
|
||||
<button
|
||||
class="ke-stat"
|
||||
[class.active]="stageFilter === key"
|
||||
[class]="'ke-stat ke-stat--' + stageMeta[key].tone"
|
||||
[class.ke-stat--on]="stageFilter === key"
|
||||
(click)="toggleStage(key)"
|
||||
>
|
||||
<div class="ke-stat-n">
|
||||
@switch (key) {
|
||||
@case ('INOPERATIVE') {
|
||||
{{ summary.inoperative }}
|
||||
}
|
||||
@case ('PARTIAL_FREEZE') {
|
||||
{{ summary.partialFreeze }}
|
||||
}
|
||||
@default {
|
||||
{{ summary.grace }}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="ke-stat-l">{{ stageMeta[key].label }}</div>
|
||||
<div class="ke-stat-w">{{ stageMeta[key].summary }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="ke-filters">
|
||||
<input class="ke-inp" type="text" placeholder="Search name, customer ID or account" [(ngModel)]="search" />
|
||||
|
||||
<select class="ke-sel" [(ngModel)]="riskFilter">
|
||||
<option [ngValue]="0">All risk categories</option>
|
||||
<option [ngValue]="1">Low Risk</option>
|
||||
<option [ngValue]="2">Medium Risk</option>
|
||||
<option [ngValue]="3">High Risk</option>
|
||||
</select>
|
||||
|
||||
<label class="ke-chk">
|
||||
<input type="checkbox" [(ngModel)]="notContactedOnly" /> Not yet contacted
|
||||
@if (summary.notContacted > 0) {
|
||||
<span class="ke-badge">{{ summary.notContacted }}</span>
|
||||
}
|
||||
</label>
|
||||
|
||||
<button class="ke-btn ke-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
|
||||
<span class="ke-spacer"></span>
|
||||
<span class="ke-count">{{ visibleRows.length }} of {{ summary.total }} customers</span>
|
||||
</div>
|
||||
|
||||
<!-- Worklist -->
|
||||
@if (loading) {
|
||||
<div class="ke-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading worklist…</div>
|
||||
} @else if (visibleRows.length === 0) {
|
||||
<div class="ke-empty">No customers match these filters.</div>
|
||||
} @else {
|
||||
<div class="ke-tablewrap">
|
||||
<table class="ke-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer</th>
|
||||
<th>Account</th>
|
||||
<th>Risk</th>
|
||||
<th>KYC Expiry</th>
|
||||
<th>Position</th>
|
||||
<th>What is blocked</th>
|
||||
<th>Next step</th>
|
||||
<th>Outreach</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of visibleRows; track r.clientId) {
|
||||
<tr class="ke-row" [class.open]="expanded === r.clientId" (click)="toggle(r.clientId)">
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === r.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
<div class="ke-sub mono">{{ r.clientId }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="mono">{{ r.accountNo || '—' }}</span>
|
||||
<div class="ke-sub">Branch {{ r.branchCode || '—' }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span [class]="riskClass(r.riskCategory)">{{ r.riskLabel }}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="mono">{{ r.expiryDate }}</span>
|
||||
<div class="ke-sub" [class.ke-sub--red]="r.daysOverdue > 0">{{ overdueText(r) }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span [class]="stageClass(r.stage)">{{ r.meta.label }}</span>
|
||||
</td>
|
||||
|
||||
<td class="ke-block-cell">
|
||||
@if (r.meta.blocked.length === 0) {
|
||||
<span class="ke-nothing">Nothing blocked — fully operative</span>
|
||||
} @else {
|
||||
@for (b of r.meta.blocked; track b) {
|
||||
<span class="ke-blocked">{{ b }}</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@if (r.nextActionDate) {
|
||||
<div class="ke-next">{{ r.nextActionLabel }}</div>
|
||||
<div class="ke-sub mono">{{ r.nextActionDate }}</div>
|
||||
} @else {
|
||||
<span class="ke-sub">{{ r.nextActionLabel }}</span>
|
||||
}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
@if (r.contactCount === 0) {
|
||||
<span class="ke-none">Not contacted</span>
|
||||
} @else {
|
||||
<span class="ke-times">{{ r.contactCount }} attempt{{ r.contactCount > 1 ? 's' : '' }}</span>
|
||||
<div class="ke-sub mono">last {{ r.lastContactDate }}</div>
|
||||
}
|
||||
@if (r.acknowledged) {
|
||||
<div class="ke-ack"><fa-icon icon="check"></fa-icon> Customer reached</div>
|
||||
}
|
||||
</td>
|
||||
|
||||
<td class="ke-act-cell">
|
||||
<div class="ke-acts">
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record an email to the customer"
|
||||
[disabled]="!r.email"
|
||||
(click)="openContact(r, 'EMAIL', $event)"
|
||||
>
|
||||
<fa-icon icon="envelope"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record an SMS"
|
||||
[disabled]="!r.mobile"
|
||||
(click)="openContact(r, 'SMS', $event)"
|
||||
>
|
||||
<fa-icon icon="comment-dots"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="ke-ico"
|
||||
title="Record a phone call"
|
||||
[disabled]="!r.mobile"
|
||||
(click)="openContact(r, 'PHONE_CALL', $event)"
|
||||
>
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
</button>
|
||||
<button class="ke-ico" title="Record a visit" (click)="openContact(r, 'FIELD_VISIT', $event)">
|
||||
<fa-icon icon="user-check"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ke-acts">
|
||||
<button class="ke-mini" (click)="openAck(r, $event)" [disabled]="r.acknowledged">
|
||||
<fa-icon icon="check"></fa-icon> Mark contacted
|
||||
</button>
|
||||
<button
|
||||
class="ke-mini ke-mini--go"
|
||||
title="Complete re-KYC on the customer's KYC screen. Once it is renewed the restrictions lift and this customer drops off the list."
|
||||
(click)="openKyc(r, $event)"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> Renew KYC
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded: what is allowed, and the full outreach history -->
|
||||
@if (expanded === r.clientId) {
|
||||
<tr class="ke-detail">
|
||||
<td colspan="10">
|
||||
<div class="ke-detail-grid">
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="check-circle"></fa-icon> Still allowed</h4>
|
||||
<ul>
|
||||
@for (a of r.meta.allowed; track a) {
|
||||
<li>{{ a }}</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="ke-panel-note">{{ r.meta.summary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="ban"></fa-icon> Blocked</h4>
|
||||
@if (r.meta.blocked.length === 0) {
|
||||
<div class="ke-panel-note">
|
||||
Nothing is restricted at this stage. The account operates normally while the customer is given
|
||||
time to complete re-KYC.
|
||||
</div>
|
||||
} @else {
|
||||
<ul>
|
||||
@for (b of r.meta.blocked; track b) {
|
||||
<li>{{ b }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<div class="ke-panel-note ke-panel-note--allow">
|
||||
Never held up: {{ alwaysAllowed.join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ke-panel">
|
||||
<h4><fa-icon icon="address-card"></fa-icon> Contact on file</h4>
|
||||
<div class="ke-kv">
|
||||
<span>Mobile</span><strong class="mono">{{ r.mobile || 'Not on file' }}</strong>
|
||||
</div>
|
||||
<div class="ke-kv">
|
||||
<span>Email</span><strong class="mono">{{ r.email || 'Not on file' }}</strong>
|
||||
</div>
|
||||
@if (r.acknowledged) {
|
||||
<div class="ke-ack-box">
|
||||
<strong>Customer reached</strong> on {{ r.acknowledgedOn }} by {{ r.acknowledgedBy }}.
|
||||
@if (r.acknowledgedNote) {
|
||||
<div class="ke-sub">{{ r.acknowledgedNote }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="ke-hist-h"><fa-icon icon="history"></fa-icon> Outreach history</h4>
|
||||
@if (r.contacts.length === 0) {
|
||||
<div class="ke-panel-note">
|
||||
No attempt recorded yet. Use the actions on the row to log an email, SMS, call or visit.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="ke-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Channel</th>
|
||||
<th>Destination</th>
|
||||
<th>Outcome</th>
|
||||
<th>Officer</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of r.contacts; track c.date + c.channel + c.notes) {
|
||||
<tr>
|
||||
<td class="mono">{{ c.date }}</td>
|
||||
<td>{{ c.channelLabel }}</td>
|
||||
<td class="mono">{{ c.destinationMasked || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="deliveryClass(c.deliveryStatus)">{{ deliveryText(c) }}</span>
|
||||
</td>
|
||||
<td>{{ c.officer }}</td>
|
||||
<td class="ke-notes">{{ c.notes }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Record an outreach attempt -->
|
||||
@if (contactOpen && contactRow) {
|
||||
<div class="ke-modal-bg" (click)="contactOpen = false">
|
||||
<div class="ke-modal" (click)="$event.stopPropagation()">
|
||||
<div class="ke-modal-h">
|
||||
<h3>Record customer contact</h3>
|
||||
<button class="ke-x" (click)="contactOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-b">
|
||||
<div class="ke-modal-who">
|
||||
<strong>{{ contactRow.clientName }}</strong>
|
||||
<span class="mono">{{ contactRow.accountNo }}</span>
|
||||
<span [class]="stageClass(contactRow.stage)">{{ contactRow.meta.label }}</span>
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">Channel</label>
|
||||
<div class="ke-chan">
|
||||
@for (c of channels; track c.key) {
|
||||
<button class="ke-chan-b" [class.active]="contactChannel === c.key" (click)="contactChannel = c.key">
|
||||
<fa-icon [icon]="c.icon"></fa-icon> {{ c.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ke-dest" [class.ke-dest--missing]="contactDestinationMissing">
|
||||
@if (contactDestinationMissing) {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
No destination on file for this channel — the attempt will be recorded but nothing is sent.
|
||||
} @else {
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
@if (contactChannelTransmits) {
|
||||
Will be sent to <strong>{{ contactDestination }}</strong>
|
||||
} @else {
|
||||
Recorded as made <strong>{{ contactDestination }}</strong>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">What was said or attempted <span class="ke-req">*</span></label>
|
||||
<textarea
|
||||
class="ke-ta"
|
||||
rows="4"
|
||||
[(ngModel)]="contactNotes"
|
||||
placeholder="e.g. Called on registered mobile, customer confirmed they will visit the branch with PAN and address proof next week."
|
||||
></textarea>
|
||||
|
||||
<label class="ke-lbl">Officer (optional)</label>
|
||||
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="contactOfficer" placeholder="Name of the officer" />
|
||||
|
||||
@if (contactError) {
|
||||
<div class="ke-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ contactError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-f">
|
||||
<button class="ke-btn ke-btn--ghost" (click)="contactOpen = false">Cancel</button>
|
||||
<button class="ke-btn ke-btn--primary" (click)="saveContact()" [disabled]="contactSaving">
|
||||
@if (contactSaving) {
|
||||
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
|
||||
} @else {
|
||||
Record contact
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mark the customer as reached -->
|
||||
@if (ackOpen && ackRow) {
|
||||
<div class="ke-modal-bg" (click)="ackOpen = false">
|
||||
<div class="ke-modal ke-modal--sm" (click)="$event.stopPropagation()">
|
||||
<div class="ke-modal-h">
|
||||
<h3>Mark customer as contacted</h3>
|
||||
<button class="ke-x" (click)="ackOpen = false">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-b">
|
||||
<div class="ke-modal-who">
|
||||
<strong>{{ ackRow.clientName }}</strong>
|
||||
<span class="mono">{{ ackRow.accountNo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ke-note-box">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
This records that the customer was reached and told what is required. It does not complete the re-KYC — the
|
||||
restriction ladder keeps running until the KYC record itself is renewed.
|
||||
</div>
|
||||
|
||||
<label class="ke-lbl">What the customer said</label>
|
||||
<textarea
|
||||
class="ke-ta"
|
||||
rows="3"
|
||||
[(ngModel)]="ackNote"
|
||||
placeholder="e.g. Customer acknowledged the debit freeze and will visit the branch on 12 Sep with documents."
|
||||
></textarea>
|
||||
|
||||
<label class="ke-lbl">Officer (optional)</label>
|
||||
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="ackOfficer" placeholder="Name of the officer" />
|
||||
</div>
|
||||
|
||||
<div class="ke-modal-f">
|
||||
<button class="ke-btn ke-btn--ghost" (click)="ackOpen = false">Cancel</button>
|
||||
<button class="ke-btn ke-btn--primary" (click)="saveAck()">Mark contacted</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (toastVisible) {
|
||||
<div class="ke-toast">{{ toastMsg }}</div>
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* 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 {
|
||||
--k-navy: #1a237e;
|
||||
--k-blue: #1d4ed8;
|
||||
--k-blue-l: #eff6ff;
|
||||
--k-green: #15803d;
|
||||
--k-green-l: #dcfce7;
|
||||
--k-amber: #b45309;
|
||||
--k-amber-l: #fef3c7;
|
||||
--k-red: #c0392b;
|
||||
--k-red-l: #fdecea;
|
||||
--k-crit: #7f1d1d;
|
||||
--k-crit-l: #fee2e2;
|
||||
--k-g50: #f8fafc;
|
||||
--k-g100: #f1f5f9;
|
||||
--k-g200: #e2e8f0;
|
||||
--k-g300: #cbd5e1;
|
||||
--k-g500: #64748b;
|
||||
--k-g700: #334155;
|
||||
--k-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--k-g900);
|
||||
background: var(--k-g50);
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ke-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--k-navy);
|
||||
}
|
||||
|
||||
.ke-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--k-g500);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* The rule the ladder implements, stated once at the top. */
|
||||
.ke-policy {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--k-blue-l);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--k-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--k-blue);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-allow-list {
|
||||
font-weight: 600;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-warnbar {
|
||||
padding: 9px 13px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--k-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
/* ── Ladder cards ───────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ke-stat {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-left: 4px solid var(--k-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-left-color: var(--k-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
border-left-color: var(--k-red);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: var(--k-crit);
|
||||
}
|
||||
|
||||
/* The selected rung stays obvious while its rows are being worked. */
|
||||
&--on {
|
||||
background: var(--k-g100);
|
||||
box-shadow: inset 0 0 0 1px var(--k-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-stat-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ke-stat-l {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ke-stat-w {
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Filters ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ke-inp,
|
||||
.ke-sel {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-inp {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.ke-chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ke-badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ke-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ke-count {
|
||||
font-size: 11.5px;
|
||||
color: var(--k-g500);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 5px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--k-blue);
|
||||
border-color: var(--k-blue);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-tablewrap {
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.ke-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--k-g100);
|
||||
border-bottom: 1px solid var(--k-g200);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--k-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--k-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--k-g50);
|
||||
}
|
||||
|
||||
&.open {
|
||||
background: var(--k-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
margin-top: 2px;
|
||||
|
||||
&--red {
|
||||
color: var(--k-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Badges ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-stage {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--info {
|
||||
background: var(--k-blue-l);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: var(--k-crit-l);
|
||||
color: var(--k-crit);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-risk {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
&--low {
|
||||
background: var(--k-green-l);
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
&--mid {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-block-cell {
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.ke-blocked {
|
||||
display: inline-block;
|
||||
margin: 0 3px 3px 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--k-red-l);
|
||||
color: var(--k-red);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Nothing restricted is good news, not an absence of information. */
|
||||
.ke-nothing {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-none {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-times {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ke-ack {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
/* ── Row actions ────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-act-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ke-acts {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ke-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--k-g700);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
background: var(--k-blue-l);
|
||||
}
|
||||
|
||||
/* A channel with no destination on file cannot be attempted. */
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--go {
|
||||
border-color: var(--k-blue);
|
||||
color: var(--k-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expanded detail ────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-detail {
|
||||
background: var(--k-g50);
|
||||
|
||||
> td {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ke-panel {
|
||||
padding: 11px 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 6px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 17px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-panel-note {
|
||||
margin-top: 7px;
|
||||
font-size: 10.5px;
|
||||
color: var(--k-g500);
|
||||
line-height: 1.5;
|
||||
|
||||
&--allow {
|
||||
color: var(--k-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 3px 0;
|
||||
font-size: 11.5px;
|
||||
|
||||
span {
|
||||
color: var(--k-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-ack-box {
|
||||
margin-top: 8px;
|
||||
padding: 7px 9px;
|
||||
background: var(--k-green-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
.ke-hist-h {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
background: var(--k-g100);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--k-g500);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 9px;
|
||||
border-top: 1px solid var(--k-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-notes {
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ke-dlv {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&--ok {
|
||||
background: var(--k-green-l);
|
||||
color: var(--k-green);
|
||||
}
|
||||
|
||||
/* Anything that is not SENT means the customer was not reached — it must not read as success. */
|
||||
&--bad {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-empty {
|
||||
padding: 34px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--k-g500);
|
||||
background: #fff;
|
||||
border: 1px solid var(--k-g200);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ke-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;
|
||||
}
|
||||
|
||||
.ke-modal {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
|
||||
|
||||
&--sm {
|
||||
max-width: 460px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-modal-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 17px;
|
||||
border-bottom: 1px solid var(--k-g200);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--k-navy);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--k-g500);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ke-modal-b {
|
||||
padding: 15px 17px;
|
||||
}
|
||||
|
||||
.ke-modal-f {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 17px;
|
||||
border-top: 1px solid var(--k-g200);
|
||||
}
|
||||
|
||||
.ke-modal-who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 11px;
|
||||
margin-bottom: 11px;
|
||||
border-bottom: 1px solid var(--k-g100);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.ke-lbl {
|
||||
display: block;
|
||||
margin: 11px 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--k-g700);
|
||||
}
|
||||
|
||||
.ke-req {
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-chan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ke-chan-b {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--k-g700);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--k-blue);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--k-blue);
|
||||
border-color: var(--k-blue);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Says plainly whether anything will actually be sent. */
|
||||
.ke-dest {
|
||||
margin-top: 9px;
|
||||
padding: 7px 10px;
|
||||
background: var(--k-green-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--k-green);
|
||||
|
||||
&--missing {
|
||||
background: var(--k-amber-l);
|
||||
color: var(--k-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.ke-ta {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--k-g300);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ke-note-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--k-blue-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--k-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--k-blue);
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ke-err {
|
||||
margin-top: 10px;
|
||||
padding: 7px 10px;
|
||||
background: var(--k-red-l);
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--k-red);
|
||||
}
|
||||
|
||||
.ke-toast {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 20px;
|
||||
background: var(--k-g900);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
|
||||
z-index: 1100;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
/** 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';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
ALWAYS_ALLOWED,
|
||||
CONTACT_CHANNELS,
|
||||
KYC_EXPIRY_POLICY,
|
||||
KYC_EXPIRY_STAGES,
|
||||
KycContactRow,
|
||||
KycExpiryRow,
|
||||
KycExpiryStage,
|
||||
KycExpirySummary,
|
||||
buildExpiryRow,
|
||||
channelLabel,
|
||||
expirySummary,
|
||||
sortWorklist
|
||||
} from './kyc-expiry.compute';
|
||||
|
||||
/**
|
||||
* The re-KYC expiry worklist.
|
||||
*
|
||||
* Answers two questions an officer has about an overdue customer: what has stopped working on the
|
||||
* account, and what has been done to reach them. The restriction ladder is read-only — it is
|
||||
* derived from the expiry date, not stored — so this screen never has to be kept in step with the
|
||||
* account's real state; it says what the policy implies and leaves the account alone.
|
||||
*
|
||||
* Recording an outreach attempt does write: it posts to the contact-log API the KYC exceptions
|
||||
* screen already uses, so both screens read the same history.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-kyc-expiry',
|
||||
templateUrl: './kyc-expiry.component.html',
|
||||
styleUrls: ['./kyc-expiry.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class KycExpiryComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = false;
|
||||
errorMessage = '';
|
||||
|
||||
rows: KycExpiryRow[] = [];
|
||||
expanded: number | null = null;
|
||||
|
||||
/** Filters */
|
||||
stageFilter: KycExpiryStage | '' = '';
|
||||
riskFilter = 0;
|
||||
search = '';
|
||||
notContactedOnly = false;
|
||||
|
||||
/** Contact dialog */
|
||||
contactOpen = false;
|
||||
contactRow: KycExpiryRow | null = null;
|
||||
contactChannel = 'EMAIL';
|
||||
contactNotes = '';
|
||||
contactOfficer = '';
|
||||
contactSaving = false;
|
||||
contactError = '';
|
||||
|
||||
/** Acknowledge dialog */
|
||||
ackOpen = false;
|
||||
ackRow: KycExpiryRow | null = null;
|
||||
ackNote = '';
|
||||
ackOfficer = '';
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
readonly channels = CONTACT_CHANNELS;
|
||||
readonly policy = KYC_EXPIRY_POLICY;
|
||||
readonly alwaysAllowed = ALWAYS_ALLOWED;
|
||||
readonly stageMeta = KYC_EXPIRY_STAGES;
|
||||
/** DUE_SOON is absent by design: nothing on this screen has an unexpired KYC. */
|
||||
readonly stageKeys: KycExpiryStage[] = [
|
||||
'INOPERATIVE',
|
||||
'PARTIAL_FREEZE',
|
||||
'GRACE'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the customers whose re-KYC has lapsed, then folds in each one's outreach history.
|
||||
*
|
||||
* The server already restricts the list to lapsed records, so nothing is filtered by date here —
|
||||
* a customer whose renewal is still ahead never reaches this screen, and one who renews drops out
|
||||
* of it on the next load.
|
||||
*/
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
this.clientsService.getLapsedKyc().subscribe({
|
||||
next: (records: any[]) => {
|
||||
const clients = (records || []).map((r) => this.mapLapsed(r)).filter((c) => !!c.expiryDate);
|
||||
|
||||
if (clients.length === 0) {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(
|
||||
clients.map((c) =>
|
||||
this.clientsService.getKycContactLog(c.clientId).pipe(
|
||||
map((logs: any[]) => (logs || []).map((l) => this.mapContact(l))),
|
||||
// One customer's history failing must not blank the whole worklist — an overdue
|
||||
// account the branch cannot see is worse than one shown without its outreach.
|
||||
catchError(() => of([] as KycContactRow[]))
|
||||
)
|
||||
)
|
||||
).subscribe({
|
||||
next: (histories) => {
|
||||
this.rows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i], today)));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.rows = sortWorklist(clients.map((c) => buildExpiryRow(c, [], today)));
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Contact history could not be loaded. The worklist below is still accurate.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rows = [];
|
||||
this.loading = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the lapsed KYC worklist.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Maps one ClientKycData record onto what the ladder needs. */
|
||||
private mapLapsed(r: any): {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
riskCategory: number;
|
||||
expiryDate: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
} {
|
||||
return {
|
||||
clientId: r?.clientId,
|
||||
clientName: r?.clientDisplayName || '—',
|
||||
accountNo: r?.clientAccountNo || '',
|
||||
branchCode: r?.clientOfficeName || '',
|
||||
riskCategory: r?.riskCategory ?? 1,
|
||||
expiryDate: (r?.expiryDate || '').toString().slice(0, 10),
|
||||
// Only the masked forms come back from the server, which is what the dialog shows and all it
|
||||
// needs — the destination itself is resolved server-side when a notice is sent.
|
||||
mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
|
||||
email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '')
|
||||
};
|
||||
}
|
||||
|
||||
private mapContact(log: any): KycContactRow {
|
||||
const status = log?.deliveryStatus || 'NOT_APPLICABLE';
|
||||
return {
|
||||
date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10),
|
||||
channel: log?.channel || '',
|
||||
channelLabel: channelLabel(log?.channel || ''),
|
||||
notes: log?.notes || '',
|
||||
officer: log?.officerName || log?.loggedByUsername || '—',
|
||||
deliveryStatus: status,
|
||||
delivered: status === 'SENT',
|
||||
destinationMasked: log?.destinationMasked || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
||||
|
||||
get visibleRows(): KycExpiryRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.stageFilter && r.stage !== this.stageFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.riskFilter && r.riskCategory !== this.riskFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.notContactedOnly && r.contactCount > 0) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get summary(): KycExpirySummary {
|
||||
return expirySummary(this.rows);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.stageFilter = '';
|
||||
this.riskFilter = 0;
|
||||
this.search = '';
|
||||
this.notContactedOnly = false;
|
||||
}
|
||||
|
||||
toggleStage(stage: KycExpiryStage): void {
|
||||
this.stageFilter = this.stageFilter === stage ? '' : stage;
|
||||
this.expanded = null;
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.expanded = this.expanded === clientId ? null : clientId;
|
||||
}
|
||||
|
||||
// ── Outreach ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the contact dialog on a sensible channel.
|
||||
*
|
||||
* Email is the default only where the customer has one — offering a channel with no destination
|
||||
* behind it produces a log entry that looks like an attempt but reached nobody.
|
||||
*/
|
||||
openContact(row: KycExpiryRow, channel: string, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.contactRow = row;
|
||||
this.contactChannel = channel || (row.email ? 'EMAIL' : row.mobile ? 'SMS' : 'PHONE_CALL');
|
||||
this.contactNotes = '';
|
||||
this.contactOfficer = '';
|
||||
this.contactError = '';
|
||||
this.contactOpen = true;
|
||||
}
|
||||
|
||||
/** Whether the chosen channel has a destination on file. */
|
||||
get contactDestination(): string {
|
||||
if (!this.contactRow) {
|
||||
return '';
|
||||
}
|
||||
const ch = this.contactChannel;
|
||||
if (ch === 'EMAIL') {
|
||||
return this.contactRow.email;
|
||||
}
|
||||
if (ch === 'SMS' || ch === 'WHATSAPP' || ch === 'PHONE_CALL') {
|
||||
return this.contactRow.mobile;
|
||||
}
|
||||
// A visit is made in person, so there is no destination to check.
|
||||
return 'in person';
|
||||
}
|
||||
|
||||
get contactDestinationMissing(): boolean {
|
||||
return !this.contactDestination;
|
||||
}
|
||||
|
||||
get contactChannelTransmits(): boolean {
|
||||
return !!CONTACT_CHANNELS.find((c) => c.key === this.contactChannel)?.transmits;
|
||||
}
|
||||
|
||||
saveContact(): void {
|
||||
if (!this.contactRow || !this.contactNotes.trim()) {
|
||||
this.contactError = 'Enter what was said or attempted — the note is the record.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.contactSaving = true;
|
||||
this.contactError = '';
|
||||
const row = this.contactRow;
|
||||
|
||||
this.clientsService
|
||||
.saveKycContactLog(row.clientId, {
|
||||
channel: this.contactChannel,
|
||||
notes: this.contactNotes.trim(),
|
||||
officerName: this.contactOfficer.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.contactSaving = false;
|
||||
this.contactOpen = false;
|
||||
// The log row is written whether or not the message left the gateway, so the officer is
|
||||
// told which of the two happened rather than being left to assume it was delivered.
|
||||
const label = channelLabel(this.contactChannel);
|
||||
const status = res?.deliveryStatus;
|
||||
this.toast(
|
||||
status && status !== 'SENT' && status !== 'NOT_APPLICABLE'
|
||||
? `${label} recorded for ${row.clientName}, but it was not delivered — see the history.`
|
||||
: `${label} recorded for ${row.clientName}`
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.contactSaving = false;
|
||||
this.contactError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the contact attempt.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Acknowledgement ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marks that the customer has been reached and understands what is required.
|
||||
*
|
||||
* This is a note on the outreach, not a substitute for the re-KYC: the ladder keeps running, and
|
||||
* the account is only released once the KYC record itself is renewed on the KYC screen.
|
||||
*/
|
||||
openAck(row: KycExpiryRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.ackRow = row;
|
||||
this.ackNote = '';
|
||||
this.ackOfficer = '';
|
||||
this.ackOpen = true;
|
||||
}
|
||||
|
||||
saveAck(): void {
|
||||
if (!this.ackRow) {
|
||||
return;
|
||||
}
|
||||
const row = this.ackRow;
|
||||
// Recorded through the same contact log so the acknowledgement sits in one history with the
|
||||
// attempts that led to it, rather than in a store of its own.
|
||||
this.clientsService
|
||||
.saveKycContactLog(row.clientId, {
|
||||
channel: 'PHONE_CALL',
|
||||
notes: `[ACKNOWLEDGED] ${this.ackNote.trim() || 'Customer reached and informed of re-KYC requirement.'}`,
|
||||
officerName: this.ackOfficer.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
row.acknowledged = true;
|
||||
row.acknowledgedOn = new Date().toISOString().slice(0, 10);
|
||||
row.acknowledgedBy = this.ackOfficer.trim() || 'Current user';
|
||||
row.acknowledgedNote = this.ackNote.trim();
|
||||
this.ackOpen = false;
|
||||
this.toast(`${row.clientName} marked as contacted`);
|
||||
this.load();
|
||||
},
|
||||
error: () => {
|
||||
this.ackOpen = false;
|
||||
this.toast('Could not save the acknowledgement.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** The KYC screen is where the record is actually renewed. */
|
||||
openKyc(row: KycExpiryRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'kyc'
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Presentation ───────────────────────────────────────────────────────────
|
||||
|
||||
stageClass(stage: KycExpiryStage): string {
|
||||
return `ke-stage ke-stage--${KYC_EXPIRY_STAGES[stage].tone}`;
|
||||
}
|
||||
|
||||
riskClass(category: number): string {
|
||||
const key = category === 3 ? 'high' : category === 2 ? 'mid' : 'low';
|
||||
return `ke-risk ke-risk--${key}`;
|
||||
}
|
||||
|
||||
/** "214 days overdue" / "in 17 days", as an officer would say it. */
|
||||
overdueText(row: KycExpiryRow): string {
|
||||
if (row.daysOverdue > 0) {
|
||||
return `${row.daysOverdue} days overdue`;
|
||||
}
|
||||
if (row.daysOverdue === 0) {
|
||||
return 'Due today';
|
||||
}
|
||||
return `Due in ${Math.abs(row.daysOverdue)} days`;
|
||||
}
|
||||
|
||||
deliveryClass(status: string): string {
|
||||
return status === 'SENT' ? 'ke-dlv ke-dlv--ok' : 'ke-dlv ke-dlv--bad';
|
||||
}
|
||||
|
||||
deliveryText(c: KycContactRow): string {
|
||||
switch (c.deliveryStatus) {
|
||||
case 'SENT':
|
||||
return 'Delivered';
|
||||
case 'FAILED':
|
||||
return 'Failed';
|
||||
case 'SKIPPED_NO_DESTINATION':
|
||||
return 'No destination on file';
|
||||
case 'PROVIDER_NOT_CONFIGURED':
|
||||
return 'Gateway not configured';
|
||||
default:
|
||||
return 'Recorded offline';
|
||||
}
|
||||
}
|
||||
|
||||
toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
clearTimeout(this.toastTimer);
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What happens to an account once its periodic re-KYC falls due.
|
||||
*
|
||||
* RBI's Master Direction on KYC does not treat an overdue re-KYC as a reason to shut a customer
|
||||
* out. The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not
|
||||
* to be closed merely because periodic KYC is pending, and that restrictions come only after the
|
||||
* customer has been given notice and time to respond. So the ladder below escalates slowly, and
|
||||
* every stage before PARTIAL_FREEZE leaves the account fully operative.
|
||||
*
|
||||
* The day counts are the bank's own policy, not RBI's — the Direction requires "adequate notice"
|
||||
* without fixing a number — so they live in one place here and can be moved without touching the
|
||||
* logic that reads them.
|
||||
*/
|
||||
|
||||
/** Where an overdue customer currently sits on the restriction ladder. */
|
||||
export type KycExpiryStage =
|
||||
/** Due date approaching; nothing is restricted and notices are going out. */
|
||||
| 'DUE_SOON'
|
||||
/** Past the due date but inside the grace window. Still fully operative. */
|
||||
| 'GRACE'
|
||||
/** Debits stopped, credits still accepted. The first actual restriction. */
|
||||
| 'PARTIAL_FREEZE'
|
||||
/** Both directions restricted, barring the always-allowed credits. */
|
||||
| 'INOPERATIVE';
|
||||
|
||||
/** Policy day counts, measured forward from the KYC expiry date. */
|
||||
export const KYC_EXPIRY_POLICY = {
|
||||
/** Days past expiry before debits are stopped. */
|
||||
graceDays: 90,
|
||||
/** Days past expiry before the account is marked inoperative. */
|
||||
inoperativeDays: 180
|
||||
};
|
||||
|
||||
/**
|
||||
* Credits that must keep landing whatever the KYC position.
|
||||
*
|
||||
* Government benefit transfers are the one category RBI has specifically directed banks not to
|
||||
* hold up, and interest and the bank's own loan recoveries are the account working rather than
|
||||
* the customer transacting on it.
|
||||
*/
|
||||
export const ALWAYS_ALLOWED = [
|
||||
'DBT / government subsidy credit',
|
||||
'Interest credit',
|
||||
'Own loan EMI auto-debit',
|
||||
'Reversal of a bank-initiated entry'
|
||||
];
|
||||
|
||||
export interface KycExpiryStageMeta {
|
||||
stage: KycExpiryStage;
|
||||
label: string;
|
||||
/** Colour key the template maps to a badge class. */
|
||||
tone: 'info' | 'warn' | 'danger' | 'critical';
|
||||
/** What the customer can still do, in the officer's words. */
|
||||
allowed: string[];
|
||||
/** What has stopped. Empty while the account is still fully operative. */
|
||||
blocked: string[];
|
||||
/** One line telling the officer what this stage means. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export const KYC_EXPIRY_STAGES: Record<KycExpiryStage, KycExpiryStageMeta> = {
|
||||
DUE_SOON: {
|
||||
stage: 'DUE_SOON',
|
||||
label: 'Re-KYC due soon',
|
||||
tone: 'info',
|
||||
allowed: [
|
||||
'All debits',
|
||||
'All credits',
|
||||
'Cheque book & channels'
|
||||
],
|
||||
blocked: [],
|
||||
summary: 'Nothing is restricted. Notices are going out so the customer can complete re-KYC in time.'
|
||||
},
|
||||
GRACE: {
|
||||
stage: 'GRACE',
|
||||
label: 'Overdue — grace period',
|
||||
tone: 'warn',
|
||||
allowed: [
|
||||
'All debits',
|
||||
'All credits',
|
||||
'Cheque book & channels'
|
||||
],
|
||||
blocked: [],
|
||||
summary: 'Past the due date but still fully operative. Restrictions only follow if the customer does not respond.'
|
||||
},
|
||||
PARTIAL_FREEZE: {
|
||||
stage: 'PARTIAL_FREEZE',
|
||||
label: 'Debit freeze',
|
||||
tone: 'danger',
|
||||
allowed: [
|
||||
'All credits',
|
||||
'DBT / subsidy',
|
||||
'Interest credit'
|
||||
],
|
||||
blocked: [
|
||||
'Cash withdrawal',
|
||||
'Transfers & NEFT / RTGS / UPI out',
|
||||
'Cheque issue & clearing debits',
|
||||
'New standing instructions'
|
||||
],
|
||||
summary: 'Debits are stopped; money can still come in. The customer must visit the branch to have this lifted.'
|
||||
},
|
||||
INOPERATIVE: {
|
||||
stage: 'INOPERATIVE',
|
||||
label: 'Inoperative',
|
||||
tone: 'critical',
|
||||
allowed: [
|
||||
'DBT / subsidy',
|
||||
'Interest credit'
|
||||
],
|
||||
blocked: [
|
||||
'All debits',
|
||||
'Ordinary credits',
|
||||
'Cheque book issue',
|
||||
'All digital channels',
|
||||
'New products & renewals'
|
||||
],
|
||||
summary: 'Both directions restricted. Only the credits RBI requires be let through are still accepted.'
|
||||
}
|
||||
};
|
||||
|
||||
/** A customer whose re-KYC is due or overdue, with the ladder worked out. */
|
||||
export interface KycExpiryRow {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
/** 1 Low / 2 Medium / 3 High. */
|
||||
riskCategory: number;
|
||||
riskLabel: string;
|
||||
expiryDate: string;
|
||||
/** Negative while the due date is still ahead. */
|
||||
daysOverdue: number;
|
||||
stage: KycExpiryStage;
|
||||
meta: KycExpiryStageMeta;
|
||||
mobile: string;
|
||||
email: string;
|
||||
/** Outreach already recorded against this customer. */
|
||||
contacts: KycContactRow[];
|
||||
contactCount: number;
|
||||
lastContactDate: string;
|
||||
/** True once an officer has confirmed the customer was reached and the case can rest. */
|
||||
acknowledged: boolean;
|
||||
acknowledgedOn: string;
|
||||
acknowledgedBy: string;
|
||||
acknowledgedNote: string;
|
||||
/** The day the next restriction bites, so the officer can warn the customer precisely. */
|
||||
nextActionDate: string;
|
||||
nextActionLabel: string;
|
||||
}
|
||||
|
||||
/** One recorded attempt to reach the customer. */
|
||||
export interface KycContactRow {
|
||||
date: string;
|
||||
channel: string;
|
||||
channelLabel: string;
|
||||
notes: string;
|
||||
officer: string;
|
||||
/** SENT means it actually left the gateway; anything else means the customer was not reached. */
|
||||
deliveryStatus: string;
|
||||
delivered: boolean;
|
||||
destinationMasked: string;
|
||||
}
|
||||
|
||||
/** Channels an officer can record, matching the backend's KycContactChannel. */
|
||||
export const CONTACT_CHANNELS = [
|
||||
{ key: 'EMAIL', label: 'Email', icon: 'envelope', transmits: true },
|
||||
{ key: 'SMS', label: 'SMS', icon: 'comment-dots', transmits: true },
|
||||
{ key: 'WHATSAPP', label: 'WhatsApp', icon: 'comment-dots', transmits: true },
|
||||
{ key: 'PHONE_CALL', label: 'Phone Call', icon: 'phone', transmits: false },
|
||||
{ key: 'BRANCH_VISIT', label: 'Branch Visit', icon: 'building-columns', transmits: false },
|
||||
{ key: 'FIELD_VISIT', label: 'Field Visit', icon: 'user-check', transmits: false }
|
||||
];
|
||||
|
||||
export function channelLabel(key: string): string {
|
||||
return CONTACT_CHANNELS.find((c) => c.key === key)?.label ?? key;
|
||||
}
|
||||
|
||||
/** Days between two ISO dates, positive when `later` is after `earlier`. */
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rung of the ladder a customer is on, from how far past the due date they are.
|
||||
*
|
||||
* An acknowledged case is deliberately not exempt: acknowledging records that the customer was
|
||||
* reached, which is not the same as the re-KYC being done, and letting it hold off a freeze would
|
||||
* turn a contact note into a way of parking an overdue account indefinitely.
|
||||
*/
|
||||
export function expiryStage(daysOverdue: number): KycExpiryStage {
|
||||
if (daysOverdue >= KYC_EXPIRY_POLICY.inoperativeDays) {
|
||||
return 'INOPERATIVE';
|
||||
}
|
||||
if (daysOverdue >= KYC_EXPIRY_POLICY.graceDays) {
|
||||
return 'PARTIAL_FREEZE';
|
||||
}
|
||||
if (daysOverdue >= 0) {
|
||||
return 'GRACE';
|
||||
}
|
||||
return 'DUE_SOON';
|
||||
}
|
||||
|
||||
/** The next restriction and the day it lands, worded for the customer. */
|
||||
export function nextAction(expiryDate: string, stage: KycExpiryStage): { date: string; label: string } {
|
||||
switch (stage) {
|
||||
case 'DUE_SOON':
|
||||
return { date: expiryDate, label: 'Re-KYC falls due' };
|
||||
case 'GRACE':
|
||||
return {
|
||||
date: addDays(expiryDate, KYC_EXPIRY_POLICY.graceDays),
|
||||
label: 'Debit freeze applies'
|
||||
};
|
||||
case 'PARTIAL_FREEZE':
|
||||
return {
|
||||
date: addDays(expiryDate, KYC_EXPIRY_POLICY.inoperativeDays),
|
||||
label: 'Account marked inoperative'
|
||||
};
|
||||
default:
|
||||
// Nothing further is applied automatically — closure is a manual decision with its own notice.
|
||||
return { date: '', label: 'No further automatic restriction' };
|
||||
}
|
||||
}
|
||||
|
||||
export const RISK_LABELS: Record<number, string> = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' };
|
||||
|
||||
/** Builds one worklist row from a KYC record and its contact history. */
|
||||
export function buildExpiryRow(
|
||||
kyc: {
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
accountNo?: string;
|
||||
branchCode?: string;
|
||||
riskCategory?: number;
|
||||
expiryDate: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
acknowledged?: boolean;
|
||||
acknowledgedOn?: string;
|
||||
acknowledgedBy?: string;
|
||||
acknowledgedNote?: string;
|
||||
},
|
||||
contacts: KycContactRow[],
|
||||
today: string
|
||||
): KycExpiryRow {
|
||||
const daysOverdue = daysBetween(kyc.expiryDate, today);
|
||||
const stage = expiryStage(daysOverdue);
|
||||
const next = nextAction(kyc.expiryDate, stage);
|
||||
const risk = kyc.riskCategory ?? 1;
|
||||
const sorted = [...contacts].sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return {
|
||||
clientId: kyc.clientId,
|
||||
clientName: kyc.clientName,
|
||||
accountNo: kyc.accountNo || '',
|
||||
branchCode: kyc.branchCode || '',
|
||||
riskCategory: risk,
|
||||
riskLabel: RISK_LABELS[risk] ?? 'Low Risk',
|
||||
expiryDate: kyc.expiryDate,
|
||||
daysOverdue,
|
||||
stage,
|
||||
meta: KYC_EXPIRY_STAGES[stage],
|
||||
mobile: kyc.mobile || '',
|
||||
email: kyc.email || '',
|
||||
contacts: sorted,
|
||||
contactCount: sorted.length,
|
||||
lastContactDate: sorted.length ? sorted[0].date : '',
|
||||
acknowledged: !!kyc.acknowledged,
|
||||
acknowledgedOn: kyc.acknowledgedOn || '',
|
||||
acknowledgedBy: kyc.acknowledgedBy || '',
|
||||
acknowledgedNote: kyc.acknowledgedNote || '',
|
||||
nextActionDate: next.date,
|
||||
nextActionLabel: next.label
|
||||
};
|
||||
}
|
||||
|
||||
export interface KycExpirySummary {
|
||||
total: number;
|
||||
grace: number;
|
||||
partialFreeze: number;
|
||||
inoperative: number;
|
||||
/** Lapsed customers nobody has reached out to yet — the real backlog. */
|
||||
notContacted: number;
|
||||
acknowledged: number;
|
||||
}
|
||||
|
||||
export function expirySummary(rows: KycExpiryRow[]): KycExpirySummary {
|
||||
return {
|
||||
total: rows.length,
|
||||
grace: rows.filter((r) => r.stage === 'GRACE').length,
|
||||
partialFreeze: rows.filter((r) => r.stage === 'PARTIAL_FREEZE').length,
|
||||
inoperative: rows.filter((r) => r.stage === 'INOPERATIVE').length,
|
||||
notContacted: rows.filter((r) => r.contactCount === 0).length,
|
||||
acknowledged: rows.filter((r) => r.acknowledged).length
|
||||
};
|
||||
}
|
||||
|
||||
/** Sorts the worklist so the most restricted, least contacted cases come first. */
|
||||
export function sortWorklist(rows: KycExpiryRow[]): KycExpiryRow[] {
|
||||
const rank: Record<KycExpiryStage, number> = {
|
||||
INOPERATIVE: 0,
|
||||
PARTIAL_FREEZE: 1,
|
||||
GRACE: 2,
|
||||
DUE_SOON: 3
|
||||
};
|
||||
return [...rows].sort(
|
||||
(a, b) =>
|
||||
rank[a.stage] - rank[b.stage] ||
|
||||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||||
a.contactCount - b.contactCount ||
|
||||
b.daysOverdue - a.daysOverdue
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user