feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -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 &amp; 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">&times;</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">&times;</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;
}
+431
View File
@@ -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);
}
}
+328
View File
@@ -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
);
}