feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -190,66 +190,47 @@
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-a"><fa-icon icon="calendar-alt"></fa-icon></div>
<div class="km-ct">3-Notice Dispatch Timeline</div>
<span class="km-ch-hint">Expiry: 30 Apr 2027 · Window: 31 Mar – 30 Apr</span>
<div class="km-ct">Escalation Ladder — one channel at a time</div>
<span class="km-ch-hint">
{{ reKycCompletedCount }} of {{ reKycSteps.length }} dispatched · Expiry: 30 Apr 2027 · Window: 31 Mar –
30 Apr
</span>
</div>
<div class="km-cb">
<div class="km-ntl">
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd1">1</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 1 — Alert window opens</div>
<div class="km-ns">31 March 2027 · Auto-scheduled · <span class="km-ok">✓ Delivered</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<!-- One channel per rung: the next is released only when the current one is dispatched. -->
@for (s of reKycSteps; track s.step; let i = $index) {
<div class="km-nt" [class.km-nt-locked]="s.status === 'LOCKED'">
<div class="km-nd-col">
<div class="km-nd" [ngClass]="'km-nd' + s.notice">{{ s.step }}</div>
<div class="km-nl"></div>
</div>
<div>
@if (isFirstOfNotice(i)) {
<div class="km-nnotice">Notice {{ s.notice }}</div>
}
<div class="km-nh">Step {{ s.step }} — {{ s.channel }}</div>
<div class="km-ns">
{{ s.date }} · {{ s.offset }} ·
<span [ngClass]="reKycStatusClass(s.status)">{{ reKycStatusLabel(s.status) }}</span>
</div>
<div class="km-ntags">
<span class="km-cp" [ngClass]="s.chip">{{ s.channel }}</span>
<span class="km-nchdetail">{{ s.detail }}</span>
</div>
@if (s.status === 'PENDING') {
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="dispatchReKycStep(s.step)">
Dispatch {{ s.channel }}
</button>
<button class="km-btn km-btn-ot" (click)="dispatchReKycStep(s.step, true)">
Mark failed — escalate
</button>
</div>
}
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd2">2</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 2 — Follow-up</div>
<div class="km-ns">10 April 2027 · +10 days · <span class="km-pending">⏳ Pending</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR Call</span>
<span class="km-cp km-cp-post">Reg. Post</span>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-nd3">3</div>
<div class="km-nl"></div>
</div>
<div>
<div class="km-nh">Notice 3 — Final notice</div>
<div class="km-ns">20 April 2027 · +20 days · <span class="km-fail">✗ Not dispatched</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR</span>
<span class="km-cp km-cp-post">Reg. Post</span>
<span class="km-cp km-cp-vis">Home Visit</span>
</div>
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="toast('Notice 3 scheduled for 20 Apr 2027')">
Schedule Notice 3
</button>
<button class="km-btn km-btn-ot" (click)="toast('Video KYC link added to Notice 3')">
+ Video KYC Link
</button>
</div>
</div>
</div>
}
<div class="km-nt">
<div class="km-nd-col">
<div class="km-nd km-ndf">!</div>
@@ -295,39 +276,26 @@
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-b"><fa-icon icon="broadcast-tower"></fa-icon></div>
<div class="km-ct">Channels — Notice 3</div>
<div class="km-ct">Channel Sequence</div>
</div>
<!-- Mirrors the ladder: exactly one channel is live, so this is a progress read-out rather
than a set of switches that could all be turned on at once. -->
<div class="km-cb km-channels">
<div class="km-channel km-channel-on" (click)="toast('SMS toggled')">
<div class="km-channel-i">📱</div>
<div class="km-channel-n">SMS</div>
<div class="km-channel-d">Registered mobile</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Email toggled')">
<div class="km-channel-i">📧</div>
<div class="km-channel-n">Email</div>
<div class="km-channel-d">Registered email</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('IVR toggled')">
<div class="km-channel-i">📞</div>
<div class="km-channel-n">IVR Call</div>
<div class="km-channel-d">Outbound IVR</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Reg Post toggled')">
<div class="km-channel-i">📮</div>
<div class="km-channel-n">Reg. Post</div>
<div class="km-channel-d">India Post</div>
</div>
<div class="km-channel" (click)="toast('Branch visit toggled')">
<div class="km-channel-i">🏦</div>
<div class="km-channel-n">Home Visit</div>
<div class="km-channel-d">Manual</div>
</div>
<div class="km-channel" (click)="toast('Video KYC toggled')">
<div class="km-channel-i">📹</div>
<div class="km-channel-n">Video KYC</div>
<div class="km-channel-d">V-CIP app</div>
</div>
@for (s of reKycSteps; track s.step) {
<div
class="km-channel"
[class.km-channel-on]="s.status === 'DELIVERED'"
[class.km-channel-live]="s.status === 'PENDING'"
[class.km-channel-off]="s.status === 'LOCKED'"
[title]="reKycStatusLabel(s.status)"
>
<div class="km-channel-n">{{ s.step }}. {{ s.channel }}</div>
<div class="km-channel-d">{{ s.detail }}</div>
<div class="km-channel-s" [ngClass]="reKycStatusClass(s.status)">
{{ reKycStatusLabel(s.status) }}
</div>
</div>
}
</div>
</div>
@@ -734,8 +702,9 @@
<div class="km-page-head">
<span class="km-page-title">KYC Exceptions Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
<fa-icon icon="download"></fa-icon> Export Exceptions
<button class="km-btn km-btn-g" [disabled]="exportingExceptions" (click)="exportExceptions()">
<fa-icon icon="download"></fa-icon>
{{ exportingExceptions ? 'Preparing…' : 'Export Exceptions' }}
</button>
</div>
</div>
@@ -743,6 +743,57 @@
border-color: #c4b5fd;
}
/* Escalation ladder: a rung waiting on an earlier channel is dimmed so the one actionable
dispatch is unmistakable. */
.km-nt-locked {
opacity: 0.55;
}
.km-locked {
color: #94a3b8;
font-weight: 500;
}
.km-nnotice {
display: inline-block;
margin-bottom: 4px;
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.km-nchdetail {
color: #64748b;
font-size: 11px;
}
/* The one live channel is outlined; completed ones stay green, waiting ones recede. */
.km-channel-live {
border-color: var(--km-amber, #f59e0b) !important;
background: #fffbeb !important;
box-shadow: 0 0 0 2px rgb(245 158 11 / 18%);
}
.km-channel-off {
opacity: 0.5;
}
.km-channel-s {
margin-top: 4px;
font-size: 10px;
font-weight: 700;
}
/* Sequence cards are read-only, so they must not invite a click the way the old toggles did. */
.km-channels .km-channel {
cursor: default;
}
.km-cp-post {
background: #fff7ed;
color: #c2410c;
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../clients/clients.service';
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
interface KmNotice {
no: string;
@@ -997,6 +998,290 @@ export class KycManagementComponent implements OnInit {
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
}
// ── Re-KYC dispatch ladder ─────────────────────────────────────────────────
/**
* Re-KYC outreach is one channel at a time, escalating only when the previous channel has been
* dispatched — not a bundle of channels fired at once. The regulatory 3-notice structure is kept
* as the grouping (notice), while each row is a single dispatch, which is how it actually happens
* at the branch.
*
* status: DELIVERED / FAILED are done, PENDING is the one dispatch that may go out now, LOCKED is
* waiting on an earlier channel.
*/
reKycSteps: {
step: number;
notice: number;
channel: string;
detail: string;
date: string;
offset: string;
status: 'DELIVERED' | 'FAILED' | 'PENDING' | 'LOCKED';
chip: string;
}[] = [
{
step: 1,
notice: 1,
channel: 'SMS',
detail: 'Registered mobile',
date: '31 March 2027',
offset: 'Window opens',
status: 'DELIVERED',
chip: 'km-cp-sms'
},
{
step: 2,
notice: 1,
channel: 'Email',
detail: 'Registered email',
date: '3 April 2027',
offset: '+3 days',
status: 'DELIVERED',
chip: 'km-cp-mail'
},
{
step: 3,
notice: 2,
channel: 'IVR Call',
detail: 'Outbound IVR',
date: '10 April 2027',
offset: '+10 days',
status: 'PENDING',
chip: 'km-cp-ivr'
},
{
step: 4,
notice: 2,
channel: 'Reg. Post',
detail: 'India Post, tracked',
date: '14 April 2027',
offset: '+14 days',
status: 'LOCKED',
chip: 'km-cp-post'
},
{
step: 5,
notice: 3,
channel: 'Home Visit',
detail: 'Field officer, manual',
date: '20 April 2027',
offset: '+20 days',
status: 'LOCKED',
chip: 'km-cp-vis'
},
{
step: 6,
notice: 3,
channel: 'Video KYC',
detail: 'V-CIP link',
date: '25 April 2027',
offset: '+25 days',
status: 'LOCKED',
chip: 'km-cp-vid'
}
];
/** The single dispatch that may go out now — nothing later is actionable until it is done. */
get currentReKycStep(): number | null {
return this.reKycSteps.find((s) => s.status === 'PENDING')?.step ?? null;
}
get reKycCompletedCount(): number {
return this.reKycSteps.filter((s) => s.status === 'DELIVERED' || s.status === 'FAILED').length;
}
/** First row of a notice carries the notice heading; the rest indent under it. */
isFirstOfNotice(index: number): boolean {
return index === 0 || this.reKycSteps[index - 1].notice !== this.reKycSteps[index].notice;
}
reKycStatusLabel(status: string): string {
switch (status) {
case 'DELIVERED':
return '✓ Dispatched';
case 'FAILED':
return '✗ Failed — escalated';
case 'PENDING':
return '⏳ Due now';
default:
return '🔒 Waiting on previous channel';
}
}
reKycStatusClass(status: string): string {
switch (status) {
case 'DELIVERED':
return 'km-ok';
case 'FAILED':
return 'km-fail';
case 'PENDING':
return 'km-pending';
default:
return 'km-locked';
}
}
/**
* Marks the due channel dispatched and releases only the next one, so the ladder can never skip a
* rung. A failure still advances — the point of escalation is that the next channel is tried.
*/
dispatchReKycStep(step: number, failed = false): void {
const index = this.reKycSteps.findIndex((s) => s.step === step);
if (index < 0 || this.reKycSteps[index].status !== 'PENDING') {
return;
}
const current = this.reKycSteps[index];
current.status = failed ? 'FAILED' : 'DELIVERED';
const next = this.reKycSteps[index + 1];
if (next && next.status === 'LOCKED') {
next.status = 'PENDING';
}
this.toast(
failed
? `${current.channel} failed — escalated to ${next ? next.channel : 'freeze workflow'}`
: `${current.channel} dispatched${next ? ` · next: ${next.channel} on ${next.date}` : ''}`
);
}
// ── Exceptions export ──────────────────────────────────────────────────────
/** True while the workbook is being built, so the button cannot be double-fired. */
exportingExceptions = false;
/**
* Exports the Exceptions Register to Excel: one sheet for pending, one for resolved, mirroring
* the columns on screen. Contact attempts are flattened into a single cell per row rather than a
* third sheet — an auditor reading a row wants the outreach trail beside it, not a lookup.
*/
async exportExceptions(): Promise<void> {
if (this.exportingExceptions) {
return;
}
const sheets: ExportSheetSpec[] = [];
if (this.pendingExceptions.length) {
sheets.push(this.exceptionSheet('Pending Exceptions', this.pendingExceptions, false));
}
if (this.resolvedExceptions.length) {
sheets.push(this.exceptionSheet('Resolved Exceptions', this.resolvedExceptions, true));
}
if (!sheets.length) {
this.toast('There are no exceptions to export.');
return;
}
this.exportingExceptions = true;
try {
const stamp = new Date().toISOString().slice(0, 10);
await exportWorkbook(sheets, `KYC-Exceptions-${stamp}`);
this.toast('Exception report downloaded.');
} catch {
// A failed export must say so — the old stub toasted success unconditionally.
this.toast('Could not build the exception report. Please try again.');
} finally {
this.exportingExceptions = false;
}
}
private exceptionSheet(sheetName: string, rows: KmClient[], resolved: boolean): ExportSheetSpec {
const headers = [
'Client',
'Account No',
'Branch',
'Exception Reason',
'Opened On',
'Risk',
'Contact Status',
'Last Contacted',
'Attempts',
'Contact Log',
'Mobile',
'Email',
'PAN',
'PAN Status',
'Aadhaar',
'Aadhaar Status',
...(resolved ? [
'Resolved On',
'Resolution Remarks'
] : [])
];
const body = rows.map((c) => {
const history = c.contactHistory || [];
const last = history.length ? history[history.length - 1] : null;
return [
c.name || '',
c.account || '',
c.contact?.branch || '',
c.exceptionReason || '',
c.exceptionDate || '',
c.riskLabel || '',
history.length ? 'Contacted' : 'Not Contacted',
last?.date || '',
// Plain count, not a formatted number — exportWorkbook applies #,##0.00 to numbers and
// "3.00 attempts" reads as a bug.
String(history.length),
this.contactLogText(history),
c.contact?.mobile || '',
c.contact?.email || '',
c.kyc?.pan || '',
c.kyc?.panStatus || '',
c.kyc?.aadhaar || '',
c.kyc?.aadhaarStatus || '',
...(resolved ? [
c.exceptionResolvedOn || '',
c.exceptionRemarks || ''
] : [])
];
});
return {
sheetName,
title: `KYC Exceptions Register — ${sheetName}`,
subtitle: `${rows.length} exception(s) · exported ${new Date().toLocaleString('en-IN')}`,
headers,
rows: body,
widths: [
26,
18,
16,
34,
14,
12,
14,
16,
10,
60,
16,
26,
14,
14,
16,
16,
...(resolved ? [
14,
40
] : [])
]
};
}
/**
* One entry per attempt, carrying the delivery outcome so a failed notice is never read as sent.
* Separated with " ; " rather than newlines: the shared sheet builder does not set wrapText on
* body cells, so embedded newlines would collapse into one cramped line in Excel.
*/
private contactLogText(history: KmContactLog[]): string {
return history
.map((log) => {
const outcome = log.deliveryStatus ? ` [${log.deliveryStatus}]` : '';
const officer = log.officer ? ` — ${log.officer}` : '';
const notes = log.notes ? `: ${log.notes}` : '';
return `${log.date} (${log.action})${notes}${officer}${outcome}`;
})
.join(' ; ');
}
/**
* Routes a "Take Action" click. Opens the Home Visit dialog if the action is
* a home-visit scheduling action; otherwise falls back to a toast confirmation.