feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user