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