Files
cbsnexora/src/app/kyc-management/kyc-management.component.ts
T

2775 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/.
*/
import { Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
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';
import { firstValueFrom, forkJoin, from, of } from 'rxjs';
import { catchError, concatMap, map } from 'rxjs/operators';
import { toIsoDate } from 'app/core/utils/api-date';
import { businessTodayIso } from 'app/core/utils/business-today';
import {
ALWAYS_ALLOWED,
KYC_EXPIRY_POLICY,
KycContactRow,
KycExpiryRow,
KycExpirySummary,
buildExpiryRow,
channelLabel,
expirySummary,
sortWorklist
} from '../kyc-expiry/kyc-expiry.compute';
import {
DORMANCY_POLICY,
DormancyRow,
DormancySummary,
dormancySummary,
nextStage,
sortByIdle,
stageFromSubStatus
} from '../dormancy-report/dormancy-report.compute';
interface KmNotice {
no: string;
channel: string;
date: string;
ref: string;
delivery: string;
response: string;
}
interface KmEvidence {
type: 'visit' | 'vkyc';
date: string;
officer: string;
gps?: string;
location?: string;
ref?: string;
outcome: string;
docket?: string;
}
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
interface KmContactLog {
date: string;
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
action: string;
notes: string;
officer: string;
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
platformSent?: boolean;
/** True only when the notice actually reached the gateway. */
delivered?: boolean;
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
deliveryStatus?: string;
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
deliveryMessage?: string;
destinationMasked?: string;
}
interface KmClient {
id: string;
av: string;
name: string;
meta: string;
state: 'alert' | 'freeze' | 'inop' | 'dormant' | 'urgent';
risk: 'low' | 'mid' | 'high';
riskLabel: string;
expiry: string;
daysLeft: number;
account: string;
kyc: {
type: string;
initiated: string;
lastVerified: string;
validity: string;
pan: string;
aadhaar: string;
panStatus: string;
aadhaarStatus: string;
panLink: string;
};
contact: {
mobile: string;
email: string;
address: string;
branch: string;
};
notices: KmNotice[];
noticesSent: number;
noticePending: string;
nextStage: string;
pendingActions: string[];
actionTaken: string[];
evidence: KmEvidence | null;
isException?: boolean;
exceptionReason?: string;
exceptionRemarks?: string;
exceptionDate?: string;
exceptionResolved?: boolean;
exceptionResolvedOn?: string;
contactHistory?: KmContactLog[];
kycId?: string;
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
hasMobile?: boolean;
hasEmail?: boolean;
}
interface KmBulkTarget {
clientId: number;
name: string;
account: string;
mobile: string;
email?: string;
noticeType: string;
}
@Component({
selector: 'mifosx-kyc-management',
templateUrl: './kyc-management.component.html',
styleUrls: ['./kyc-management.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class KycManagementComponent implements OnInit, OnDestroy {
private clientsService = inject(ClientsService);
private router = inject(Router);
activeSection: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy' = 'alerts';
/** Customer the contact / resolve dialogs are open for. */
current: KmClient | null = null;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
exceptionRows: KmClient[] = [];
pendingExceptions: KmClient[] = [];
resolvedExceptions: KmClient[] = [];
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
selectedExceptionReason = '';
selectedExceptionContactStatus = '';
// Dialog properties for exceptions
contactDialogOpen = false;
contactChannel = 'PHONE_CALL';
contactNotes = '';
contactOfficer = '';
/** Set when the dialog was opened from the dashboard: the stage-specific notice the customer gets. */
contactNoticeType = '';
contactPreview: { subject: string; body: string } | null = null;
/** In-flight guard — a double-click must not send the customer two notices. */
contactSaving = false;
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
contactError = '';
/**
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
* transmits a real notice to the customer, as opposed to recording an action the officer already
* took offline — the dialog labels and the confirm button both key off it.
*/
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
];
resolveDialogOpen = false;
resolveDocumentType = 'PAN';
resolveDocumentNumber = '';
resolveRemarks = '';
resolvePanActive = false;
resolvePanNumber = '';
resolveAadhaarActive = false;
resolveAadhaarNumber = '';
resolveGeneralActive = false;
resolveGeneralDocumentType = 'OVD';
resolveGeneralDocumentNumber = '';
showPanResolution = false;
showAadhaarResolution = false;
showGeneralResolution = false;
// ── Live status data (Account Status / Notification Scheduler / Audit Inventory) ──────────────
statusLoading = false;
statusError = '';
expiryRows: KycExpiryRow[] = [];
/** Re-KYC due within the next 30 days — not lapsed yet, so kept apart from {@link expiryRows}. */
expiringRows: KycExpiryRow[] = [];
dormancyRows: DormancyRow[] = [];
expiryTotals: KycExpirySummary = expirySummary([]);
dormancyTotals: DormancySummary = dormancySummary([], businessTodayIso());
readonly expiryPolicy = KYC_EXPIRY_POLICY;
readonly dormancyPolicy = DORMANCY_POLICY;
readonly alwaysAllowed = ALWAYS_ALLOWED;
/** Every outreach attempt on record across the lapsed customers, newest first. */
get auditRows(): { row: KycExpiryRow; log: KycContactRow }[] {
return this.expiryRows
.flatMap((row) => row.contacts.map((log) => ({ row, log })))
.sort((a, b) => (b.log.date || '').localeCompare(a.log.date || ''));
}
/** Reads the same sources as the Expired KYC and Dormancy reports, so the figures cannot differ. */
/** Card picked on the Alert Dashboard; its accounts are listed beneath the cards. */
/** The page opens on the Expiring in 30 days card. */
alertFilter: 'EXPIRING' | 'GRACE' | 'PARTIAL_FREEZE' | 'INOPERATIVE' | 'INACTIVE' | 'DORMANT' | 'ESCHEAT' | null =
'EXPIRING';
readonly alertLabels: Record<string, string> = {
EXPIRING: 'Expiring in 30 days',
GRACE: 'Overdue — Grace Period',
PARTIAL_FREEZE: 'Debit Freeze',
INOPERATIVE: 'Inoperative',
INACTIVE: 'Inactive',
DORMANT: 'Dormant',
ESCHEAT: 'Escheat (DEAF)'
};
selectAlert(key: NonNullable<KycManagementComponent['alertFilter']>): void {
this.alertFilter = this.alertFilter === key ? null : key;
this.datePage = 0;
this.leaveAlertDay();
this.loadReachForList();
}
/**
* A day's list is its own step in the browser history. The browser's Back button, or the Back button on the page, both end
* up here and return to the list of days instead of leaving the KYC page.
*/
@HostListener('window:popstate')
onBrowserBack(): void {
const state = history.state || {};
if (this.alertDateOpen && !state.kmDay) {
this.alertDateOpen = null;
}
if (this.auditDay && !state.kmAuditDay) {
this.auditDay = null;
this.auditDayRows = [];
}
}
/** Closes the open day on the Alert Dashboard, taking its history step with it so Back does not need pressing twice. */
private leaveAlertDay(): void {
if (!this.alertDateOpen) {
return;
}
this.alertDateOpen = null;
if (history.state?.kmDay) {
history.back();
}
}
private leaveAuditDay(): void {
if (!this.auditDay) {
return;
}
this.auditDay = null;
this.auditDayRows = [];
if (history.state?.kmAuditDay) {
history.back();
}
}
/** The two full reports live on their own pages; the dashboard links to them. */
openReport(path: string): void {
this.router.navigate([path]);
}
/** Filters above the cards; they narrow the list of accounts beneath them. */
alertSearch = '';
alertRisk = 0;
alertContact: '' | 'NONE' | 'CONTACTED' | 'ACK' = '';
/** Whether a customer has a mobile number or email address to be sent anything at all. */
alertReach: '' | 'NONE' | 'OK' = '';
get alertFiltersActive(): boolean {
return !!(this.alertFilter || this.alertSearch.trim() || this.alertRisk || this.alertContact || this.alertReach);
}
resetAlertFilters(): void {
this.leaveAlertDay();
this.alertFilter = null;
this.alertSearch = '';
this.alertRisk = 0;
this.alertContact = '';
this.alertReach = '';
}
// ── The list by date: how many fell on each day, and who ──────────────────
/** The day open in the popup. */
alertDateOpen: string | null = null;
/** What the date means for the card in view: KYC expiry for the KYC states, last customer transaction otherwise. */
get alertDateHeading(): string {
const f = this.alertFilter;
if (!f) {
return 'Date';
}
return f === 'INACTIVE' || f === 'DORMANT' || f === 'ESCHEAT' ? 'Last customer transaction' : 'KYC expiry date';
}
/** The matching accounts grouped by that date, the most urgent (oldest or soonest) day first. */
get alertDateGroups(): {
date: string;
rows: KycManagementComponent['alertList'];
customers: number;
balance: number;
/** Customers that something has been done for (a notice delivered, or a call / visit recorded). */
notified: number;
/** Notices sent to these customers at stages before this one. */
earlier: number;
/** Customers with neither a mobile number nor an email address. */
unreachable: number;
/** False until the notice history of every customer on the day has come back. */
loaded: boolean;
/** The card these customers move to next, and the day they get there. */
after: { label: string; date: string };
}[] {
const byDate = new Map<string, KycManagementComponent['alertList']>();
for (const r of this.alertList) {
const day = r.since || '';
byDate.set(day, [
...(byDate.get(day) || []),
r
]);
}
return [...byDate.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(
([
date,
rows
]) => {
const ids = [...new Set(rows.map((r) => r.clientId))];
return {
date,
rows,
customers: ids.length,
// A KYC row carries no balance of its own; it is the customer's total, counted once per customer.
balance: ids.reduce(
(n, id) =>
n + Number(this.alertNoticeInfo[id]?.balance ?? rows.find((r) => r.clientId === id)?.balance ?? 0),
0
),
notified: ids.filter((id) => this.doneChannelsFor(id, rows[0]?.stageKey).length > 0).length,
earlier: ids.reduce((n, id) => n + this.earlierNotices(id, rows[0]?.stageKey), 0),
unreachable: ids.filter((id) => {
const c = this.alertNoticeInfo[id];
return !!c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail;
}).length,
loaded: ids.every((id) => this.alertNoticeInfo[id] !== undefined),
after: this.nextCardFor(date, rows[0])
};
}
);
}
/**
* Where a customer goes once this stage runs out. A KYC that expires moves to the grace period on its expiry date, then to
* the debit freeze after the grace days and to inoperative after the inoperative days; other stages carry their own next step.
*/
private nextCardFor(
date: string,
row?: KycManagementComponent['alertList'][number]
): { label: string; date: string } {
const plus = (days: number): string => {
const x = new Date(date + 'T00:00:00Z');
return new Date(x.getTime() + days * 86400000).toISOString().slice(0, 10);
};
switch (row?.stageKey) {
case 'EXPIRING':
return { label: this.alertLabels['GRACE'], date };
case 'GRACE':
return { label: this.alertLabels['PARTIAL_FREEZE'], date: plus(this.expiryPolicy.graceDays) };
case 'PARTIAL_FREEZE':
return { label: this.alertLabels['INOPERATIVE'], date: plus(this.expiryPolicy.inoperativeDays) };
default:
return { label: row?.next || '—', date: row?.nextDate || '' };
}
}
/**
* The channels a customer has been reached by at the stage they are at now. A notice sent at an earlier stage (the 30-day
* warning, once the KYC has expired) does not count, since the customer is owed the new one. Before the backend sends
* the stage split, every notice counts as before.
*/
doneChannelsFor(clientId: number, stageKey?: string): string[] {
const info = this.alertNoticeInfo[clientId];
if (!info) {
return [];
}
if (!info.doneByStage || !stageKey) {
return info.doneChannels || [];
}
return [
...new Set([
...(info.doneByStage[stageKey] || []),
...(info.doneByStage['ANY'] || [])
])
];
}
/** The earlier stages at which this customer was already sent a notice, by name and how many. */
earlierStages(clientId: number, stageKey?: string): string[] {
const info = this.alertNoticeInfo[clientId];
const by = info?.noticesByStage || {};
if (!stageKey) {
return [];
}
return Object.keys(by)
.filter((k) => k !== stageKey && k !== 'ANY' && by[k] > 0)
.map((k) => (this.alertLabels[k] || k) + (by[k] > 1 ? ' ×' + by[k] : ''));
}
/** How many notices this customer was sent at stages before the one they are at now. */
earlierNotices(clientId: number, stageKey?: string): number {
const by = this.alertNoticeInfo[clientId]?.noticesByStage || {};
return Object.keys(by)
.filter((k) => !!stageKey && k !== stageKey && k !== 'ANY')
.reduce((n, k) => n + by[k], 0);
}
/** Page sizes offered under the lists; the lists are cut into pages so a bank with a million customers never draws them all at once. */
readonly pageSizes = [
10,
25,
50,
100
];
/** Page and size of each pop-up list, by name. */
private pg: Record<string, { page: number; size: number }> = {};
private pgState(key: string): { page: number; size: number } {
return (this.pg[key] ||= { page: 0, size: 10 });
}
pgSize(key: string): number {
return this.pgState(key).size;
}
pgCount(key: string, total: number): number {
return Math.max(1, Math.ceil(total / this.pgSize(key)));
}
/** The page in view, kept inside the list when it shrinks. */
pgPage(key: string, total: number): number {
return Math.min(this.pgState(key).page, this.pgCount(key, total) - 1);
}
pgFrom(key: string): number {
return this.pgState(key).page * this.pgSize(key);
}
pgSlice<T>(key: string, list: T[]): T[] {
const s = this.pgState(key);
s.page = Math.min(s.page, Math.max(0, Math.ceil(list.length / s.size) - 1));
return list.slice(s.page * s.size, s.page * s.size + s.size);
}
pgMove(key: string, by: number, total: number): void {
this.pgState(key).page = Math.min(Math.max(this.pgPage(key, total) + by, 0), this.pgCount(key, total) - 1);
}
pgSetSize(key: string, size: number): void {
const s = this.pgState(key);
s.size = size;
s.page = 0;
}
min = Math.min;
dateSize = 25;
daySize = 25;
private datePage = 0;
private dayPage = 0;
/** The page in view, kept inside the list when a filter shrinks it. */
get dateEffPage(): number {
return Math.min(this.datePage, this.dateCount() - 1);
}
get dayEffPage(): number {
return Math.min(this.dayPage, this.dayCount() - 1);
}
dateCount(): number {
return Math.max(1, Math.ceil(this.alertDateGroups.length / this.dateSize));
}
dayCount(): number {
return Math.max(1, Math.ceil(this.alertDateRows.length / this.daySize));
}
datePMove(by: number): void {
this.datePage = Math.min(Math.max(this.dateEffPage + by, 0), this.dateCount() - 1);
}
datePSize(n: number): void {
this.dateSize = n;
this.datePage = 0;
}
dayPMove(by: number): void {
this.dayPage = Math.min(Math.max(this.dayEffPage + by, 0), this.dayCount() - 1);
}
dayPSize(n: number): void {
this.daySize = n;
this.dayPage = 0;
}
get pagedDateGroups(): KycManagementComponent['alertDateGroups'] {
const from = this.dateEffPage * this.dateSize;
return this.alertDateGroups.slice(from, from + this.dateSize);
}
get pagedDayRows(): KycManagementComponent['alertList'] {
const from = this.dayEffPage * this.daySize;
return this.alertDateRows.slice(from, from + this.daySize);
}
get alertDateRows(): KycManagementComponent['alertList'] {
return this.alertDateGroups.find((g) => g.date === this.alertDateOpen)?.rows || [];
}
openAlertDate(date: string): void {
this.alertDateOpen = date;
this.dayPage = 0;
// The day gets its own step in the browser history, so the browser's Back button returns to the list, not out of the page.
history.pushState({ ...history.state, kmDay: date }, '');
this.loadAlertNotices();
this.loadQueue();
}
/** What the customers in the open day have been sent, so each row can say what went last and what is next. */
alertNoticeInfo: Record<
number,
{
count: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
doneChannels: string[];
doneByStage?: Record<string, string[]>;
noticesByStage?: Record<string, number>;
balance?: number;
hasMobile?: boolean;
hasEmail?: boolean;
mobileMasked?: string | null;
emailMasked?: string | null;
}
> = {};
alertNoticeLoaded = false;
loadAlertNotices(): void {
const ids = [...new Set(this.alertDateRows.map((r) => r.clientId))];
this.alertNoticeLoaded = false;
this.fetchNoticeInfo(ids);
}
/** What each customer has been sent and whether they have a mobile number or email, fetched 500 at a time. */
private fetchNoticeInfo(ids: number[]): void {
if (!ids.length) {
this.alertNoticeLoaded = true;
return;
}
const chunks: number[][] = [];
for (let i = 0; i < ids.length; i += 500) {
chunks.push(ids.slice(i, i + 500));
}
forkJoin(
chunks.map((c) => this.clientsService.getKycContactSummary(c).pipe(catchError(() => of([] as any[]))))
).subscribe((parts) => {
const info: KycManagementComponent['alertNoticeInfo'] = { ...this.alertNoticeInfo };
parts.forEach((rows) => (rows || []).forEach((r: any) => (info[r.clientId] = r)));
this.alertNoticeInfo = info;
this.alertNoticeLoaded = true;
});
}
/** Looks up contact details for the customers on the list in view, so the ones who cannot be reached by SMS or email show. */
loadReachForList(): void {
const ids = [...new Set(this.alertListBase.map((r) => r.clientId))].filter(
(id) => this.alertNoticeInfo[id]?.hasMobile === undefined
);
this.fetchNoticeInfo(ids);
}
/** True once the customer's details are known and there is neither a mobile number nor an email on file. */
hasNoContact(clientId: number): boolean {
const c = this.alertNoticeInfo[clientId];
return !!c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail;
}
contactOf(clientId: number): KycManagementComponent['alertNoticeInfo'][number] | undefined {
return this.alertNoticeInfo[clientId];
}
/** True when the customer is known to have nowhere to send this channel (no mobile for SMS / WhatsApp, no email for Email). */
unreachableFor(clientId: number, channel: string): boolean {
const c = this.alertNoticeInfo[clientId];
if (!c || c.hasMobile === undefined) {
return false;
}
return channel === 'EMAIL' ? !c.hasEmail : !c.hasMobile;
}
/** Customers on the list in view with neither a mobile number nor an email address. */
get alertUnreachableCount(): number {
const seen = new Set<number>();
this.alertListBase.forEach((r) => {
const c = this.alertNoticeInfo[r.clientId];
if (c && c.hasMobile !== undefined && !c.hasMobile && !c.hasEmail) {
seen.add(r.clientId);
}
});
return seen.size;
}
/** Who the open history popup is about, and each customer's notices once loaded. */
historyKey: string | null = null;
historyLoading = false;
historyFor: { clientId: number; name: string; account: string } | null = null;
historyRows: Record<
number,
{ when: string; action: string; result: string; delivered: boolean; officer: string; notes: string; to: string }[]
> = {};
openHistory(r: { clientId: number; name: string; account: string }): void {
this.historyFor = { clientId: r.clientId, name: r.name, account: r.account };
this.historyKey = String(r.clientId);
this.historyLoading = true;
this.clientsService.getKycContactLog(r.clientId).subscribe({
next: (logs: any[]) => {
this.historyRows = {
...this.historyRows,
[r.clientId]: (logs || [])
.map((l) => ({
when: this.logDateTime(l?.contactedAt),
action: l?.channelLabel || l?.channel || 'Contact',
result: l?.deliveryMessage || l?.deliveryStatus || '',
delivered: !!l?.delivered || l?.deliveryStatus === 'NOT_APPLICABLE',
officer: (l?.officerName || '').split(' — ')[0],
notes: l?.notes || '',
to: l?.destinationMasked || ''
}))
.reverse()
};
this.historyLoading = false;
},
error: () => {
this.historyRows = { ...this.historyRows, [r.clientId]: [] };
this.historyLoading = false;
}
});
}
closeHistory(): void {
this.historyKey = null;
this.historyFor = null;
}
private logDateTime(value: any): string {
const d = Array.isArray(value)
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
: new Date(value);
return isNaN(d.getTime())
? '—'
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/** "18 Oct, 10:02 · SMS · Delivered", or "None yet". */
lastNoticeText(clientId: number): string {
const i = this.alertNoticeInfo[clientId];
if (!i) {
return this.alertNoticeLoaded ? 'None yet' : '…';
}
const when = (i.lastAt || '').replace('T', ' ').slice(0, 16);
return (
when +
' · ' +
(this.auditChannelLabels[i.lastChannel] || i.lastChannel) +
' · ' +
(this.auditStatusLabels[i.lastStatus] || i.lastStatus)
);
}
/**
* The next step on the ladder for this customer: the first channel not yet done that can actually reach them. A channel the
* customer has no number or address for is skipped, since it would only come back as "not sent".
*/
nextNoticeStep(r: { clientId: number; mobile: string; email?: string; stageKey?: string }): string {
if (!this.alertNoticeLoaded) {
return '…';
}
const done = this.doneChannelsFor(r.clientId, r.stageKey);
const ladder: { key: string; label: string; reachable: boolean }[] = [
{ key: 'SMS', label: 'Send SMS', reachable: !!r.mobile },
{ key: 'EMAIL', label: 'Send Email', reachable: r.email === undefined || !!r.email },
{ key: 'WHATSAPP', label: 'Send WhatsApp', reachable: !!r.mobile },
{ key: 'PHONE_CALL', label: 'Phone call', reachable: true },
{ key: 'BRANCH_VISIT', label: 'Branch visit', reachable: true },
{ key: 'FIELD_VISIT', label: 'Field visit', reachable: true }
];
const next = ladder.find((s) => !done.includes(s.key) && s.reachable);
return next ? next.label : 'Ladder complete';
}
/** The date of the next reminder the nightly job will send for this account, and why. */
nextAutoReminder(r: { stageKey: string; since: string; nextDate?: string }): { date: string; label: string } | null {
const today = businessTodayIso();
const add = (iso: string, days: number) => {
const d = new Date(iso + 'T00:00:00Z');
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
};
if (r.stageKey === 'INACTIVE' || r.stageKey === 'DORMANT') {
if (!r.nextDate) {
return null;
}
const target = r.stageKey === 'INACTIVE' ? 'dormant' : 'closed to DEAF';
const options = [
{ date: add(r.nextDate, -7), label: '7 days before it becomes ' + target },
{ date: add(r.nextDate, -1), label: '1 day before it becomes ' + target }
];
return options.find((o) => o.date > today) || null;
}
if (r.stageKey === 'ESCHEAT' || !r.since) {
return null;
}
// KYC: reminders fall on fixed days from the expiry date.
const marks: { day: number; label: string }[] = [
{ day: -30, label: '30 days before the KYC expires' },
{ day: -15, label: '15 days before the KYC expires' },
{ day: -7, label: '7 days before the KYC expires' },
{ day: -1, label: '1 day before the KYC expires' },
{ day: 0, label: 'the day the KYC expires' },
{ day: 83, label: '7 days before debits stop' },
{ day: 89, label: '1 day before debits stop' },
{ day: 90, label: 'the day debits stop' },
{ day: 173, label: '7 days before the account turns inoperative' },
{ day: 179, label: '1 day before the account turns inoperative' },
{ day: 180, label: 'the day the account turns inoperative' }
];
const next = marks.map((m) => ({ date: add(r.since, m.day), label: m.label })).find((m) => m.date > today);
return next || null;
}
closeAlertDate(): void {
this.leaveAlertDay();
}
/** True when every customer of that day is ticked. */
alertDateChecked(g: { rows: KycManagementComponent['alertList'] }): boolean {
return g.rows.length > 0 && g.rows.every((r) => this.alertSelected.has(r.clientId));
}
/** Ticks or clears every customer of that day at once. */
toggleAlertDate(g: { rows: KycManagementComponent['alertList'] }): void {
if (this.alertDateChecked(g)) {
g.rows.forEach((r) => this.alertSelected.delete(r.clientId));
} else {
g.rows.forEach((r) => this.alertSelected.add(r.clientId));
}
}
/** True when every customer on every day of the list is ticked. */
get alertAllDaysSelected(): boolean {
const rows = this.alertList;
return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId));
}
/** Ticks or clears every customer on every day, for one batch. */
toggleAllDays(): void {
const rows = this.alertList;
if (this.alertAllDaysSelected) {
rows.forEach((r) => this.alertSelected.delete(r.clientId));
} else {
rows.forEach((r) => this.alertSelected.add(r.clientId));
}
}
get alertDateAllSelected(): boolean {
const rows = this.alertDateRows;
return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId));
}
toggleAlertDateAll(): void {
const rows = this.alertDateRows;
if (this.alertDateAllSelected) {
rows.forEach((r) => this.alertSelected.delete(r.clientId));
} else {
rows.forEach((r) => this.alertSelected.add(r.clientId));
}
}
/** Accounts matching the chosen card and filters — same sources as the card counts. */
get alertList(): KycManagementComponent['alertListBase'] {
const base = this.alertListBase;
if (!this.alertReach) {
return base;
}
return base.filter((r) => {
const c = this.alertNoticeInfo[r.clientId];
// Not known yet: nobody is hidden on a guess.
if (!c || c.hasMobile === undefined) {
return true;
}
const reachable = !!(c.hasMobile || c.hasEmail);
return this.alertReach === 'NONE' ? !reachable : reachable;
});
}
/** The matching accounts before the contact-detail filter, which needs the customers' details fetched. */
get alertListBase(): {
clientId: number;
stageKey: string;
mobile: string;
email?: string;
name: string;
account: string;
branch: string;
stage: string;
since: string;
days: number;
daysLabel: string;
next: string;
nextDate: string;
balance: number | null;
}[] {
if (!this.alertFiltersActive) {
return [];
}
const needle = this.alertSearch.trim().toLowerCase();
const matches = (name: string, account: string) =>
!needle || name.toLowerCase().includes(needle) || (account || '').toLowerCase().includes(needle);
const wanted = (stage: string) => !this.alertFilter || this.alertFilter === stage;
const keyOf = (r: KycExpiryRow) => (r.stage === 'DUE_SOON' ? 'EXPIRING' : r.stage);
// Risk and outreach are properties of the KYC record; a dormancy row has neither, so it drops
// out whenever one of those filters is set rather than being shown as if it matched.
const kycOnly = !!(this.alertRisk || this.alertContact);
const kyc = [
...this.expiringRows,
...this.expiryRows
]
.filter((r) => wanted(keyOf(r)) && matches(r.clientName, r.accountNo))
.filter((r) => !this.alertRisk || r.riskCategory === this.alertRisk)
.filter(
(r) =>
!this.alertContact ||
(this.alertContact === 'NONE' && r.contactCount === 0) ||
(this.alertContact === 'CONTACTED' && r.contactCount > 0) ||
(this.alertContact === 'ACK' && r.acknowledged)
)
.map((r) => ({
clientId: r.clientId,
stageKey: keyOf(r),
mobile: r.mobile,
email: r.email as string | undefined,
name: r.clientName,
account: r.accountNo,
branch: r.branchCode,
stage: this.alertLabels[keyOf(r)] || r.meta.label,
since: r.expiryDate,
days: Math.abs(r.daysOverdue),
daysLabel: r.daysOverdue < 0 ? 'days left' : 'days overdue',
next: r.nextActionLabel,
nextDate: r.nextActionDate,
balance: null as number | null
}));
const dormant = kycOnly
? []
: this.dormancyRows
.filter((r) => wanted(r.stage) && matches(r.clientName, r.accountNo))
.map((r) => ({
clientId: r.clientId,
stageKey: r.stage as string,
mobile: r.mobile,
email: undefined as string | undefined,
name: r.clientName,
account: r.accountNo,
branch: r.branchCode,
stage: this.alertLabels[r.stage] || r.stage,
since: r.lastCustomerTxnDate,
days: r.daysIdle,
daysLabel: 'days idle',
next: r.nextStageLabel || '—',
nextDate: r.nextStageDate,
balance: r.balance as number | null
}));
return [
...kyc,
...dormant
];
}
/** Which family of notice a stage gets: a re-KYC notice, or one about an account that has gone quiet. */
noticeTypeFor(stageKey?: string): string {
return [
'INACTIVE',
'DORMANT',
'ESCHEAT'
].includes(stageKey || '') ? 'DORMANCY' : stageKey ? 'KYC' : '';
}
/** Notify: records and sends a notice through the same contact-log API the KYC screens use. */
openAlertNotify(r: {
clientId: number;
name: string;
account: string;
branch: string;
mobile: string;
email?: string;
stageKey?: string;
}): void {
const av = r.name
.split(' ')
.map((n) => n[0])
.join('')
.substring(0, 2)
.toUpperCase();
const stub: KmClient = {
id: String(r.clientId),
av,
name: r.name,
meta: r.account,
state: 'alert',
risk: 'low',
riskLabel: '',
expiry: '',
daysLeft: 0,
account: r.account,
kyc: {
type: '',
initiated: '',
lastVerified: '',
validity: '',
pan: '',
aadhaar: '',
panStatus: '',
aadhaarStatus: '',
panLink: ''
},
contact: {
// The list row may not carry the number or address (the dormancy list has no email), so fall back to what is on file.
mobile: r.mobile || this.alertNoticeInfo[r.clientId]?.mobileMasked || '—',
email: r.email || this.alertNoticeInfo[r.clientId]?.emailMasked || '—',
address: '',
branch: r.branch
},
notices: [],
noticesSent: 0,
noticePending: '',
nextStage: '',
pendingActions: [],
actionTaken: [],
evidence: null,
hasMobile: !!(r.mobile || this.alertNoticeInfo[r.clientId]?.hasMobile),
// The dormancy list carries no email; leave it unknown rather than claiming there is none.
hasEmail: this.alertNoticeInfo[r.clientId]?.hasEmail ?? (r.email === undefined ? undefined : !!r.email),
contactHistory: []
};
this.openContactDialog(stub);
this.contactNoticeType = this.noticeTypeFor(r.stageKey);
this.loadNoticePreview(r.clientId);
}
/** What the customer would receive, shown before the officer sends it. */
loadNoticePreview(clientId: number): void {
this.contactPreview = null;
if (!this.contactNoticeType) {
return;
}
this.clientsService.previewKycNotice(clientId, this.contactNoticeType).subscribe({
next: (n) => (this.contactPreview = n?.body ? { subject: n.subject || '', body: n.body } : null),
error: () => (this.contactPreview = null)
});
}
/**
* Activating a restricted account means renewing the customer's KYC: the server lifts the KYC
* block and revives inactive / dormant savings accounts when the re-KYC is completed, and does
* nothing for a plain transaction. So the KYC screen is where this happens.
*/
openAlertKyc(clientId: number): void {
this.router.navigate([
'/clients',
clientId,
'kyc'
]);
}
openAlertClient(clientId: number): void {
this.router.navigate([
'/clients',
clientId,
'general'
]);
}
// ── Bulk notify ────────────────────────────────────────────────────────────
/** Customers ticked in the list, by client id — a notice goes to the customer, not to an account. */
alertSelected = new Set<number>();
bulkOpen = false;
bulkChannel = 'SMS';
bulkNotes = '';
bulkOfficer = '';
bulkSending = false;
bulkDone = false;
/** When to send; blank means as soon as the queue job next runs (within about a minute). */
bulkSendAt = '';
bulkError = '';
bulkQueued: { queued: number; skipped: number; unreachable: number } | null = null;
bulkResults: { name: string; account: string; ok: boolean; message: string }[] = [];
/** Kept modest so one click cannot flood the SMS gateway. */
readonly bulkLimit = 50;
/** Only the channels that actually transmit; record-only channels need notes written per customer. */
readonly bulkChannels = [
{ value: 'SMS', label: 'SMS' },
{ value: 'EMAIL', label: 'Email' },
{ value: 'WHATSAPP', label: 'WhatsApp Message' }
];
toggleAlertRow(clientId: number): void {
if (this.alertSelected.has(clientId)) {
this.alertSelected.delete(clientId);
} else {
this.alertSelected.add(clientId);
}
}
get alertAllSelected(): boolean {
const rows = this.alertList;
return rows.length > 0 && rows.every((r) => this.alertSelected.has(r.clientId));
}
toggleAlertAll(): void {
const rows = this.alertList;
if (this.alertAllSelected) {
rows.forEach((r) => this.alertSelected.delete(r.clientId));
} else {
rows.forEach((r) => this.alertSelected.add(r.clientId));
}
}
/** One entry per customer: two accounts of the same customer must not get two notices. */
/** Everyone ticked, before any are left out as already notified. */
private selectedTargets(): KmBulkTarget[] {
const seen = new Set<number>();
const targets: KmBulkTarget[] = [];
for (const r of this.alertList) {
if (this.alertSelected.has(r.clientId) && !seen.has(r.clientId)) {
seen.add(r.clientId);
targets.push({
clientId: r.clientId,
name: r.name,
account: r.account,
mobile: r.mobile,
email: r.email,
noticeType: this.noticeTypeFor(r.stageKey)
});
}
}
return targets;
}
/** Leave out customers who already received this channel, so nobody is sent the same notice twice. */
bulkSkipDone = true;
hasDone(clientId: number, channel: string): boolean {
const stageKey = this.alertListBase.find((r) => r.clientId === clientId)?.stageKey;
return (
this.doneChannelsFor(clientId, stageKey).includes(channel) || this.queuedChannels(clientId).includes(channel)
);
}
/** Channels this customer already has a notice waiting on in the queue. */
queuedChannels(clientId: number): string[] {
return this.queueRows
.filter((q) => q.clientId === clientId && (q.status === 'PENDING' || q.status === 'SENDING'))
.map((q) => q.channel);
}
get bulkTargets(): KmBulkTarget[] {
const all = this.selectedTargets();
if (!this.bulkOpen) {
return all;
}
// Someone with nowhere to send it is always left out; someone already sent it is left out unless the officer says otherwise.
return all.filter(
(t) =>
!this.unreachableFor(t.clientId, this.bulkChannel) &&
!(this.bulkSkipDone && this.hasDone(t.clientId, this.bulkChannel))
);
}
/** How many of the ticked customers have no mobile number / email for the chosen channel. */
get bulkUnreachable(): number {
return this.selectedTargets().filter((t) => this.unreachableFor(t.clientId, this.bulkChannel)).length;
}
/** How many of the ticked customers already have this channel done. */
get bulkAlreadyDone(): number {
return this.selectedTargets().filter((t) => this.hasDone(t.clientId, this.bulkChannel)).length;
}
/** Fetches what these customers have been sent, for any not already known. */
private ensureNoticeInfo(ids: number[]): void {
this.fetchNoticeInfo(ids.filter((id) => this.alertNoticeInfo[id]?.hasMobile === undefined));
}
/** How many of the selected customers get each kind of message, so the officer sees what is going out. */
get bulkNoticeSummary(): { label: string; count: number }[] {
const kyc = this.bulkTargets.filter((t) => t.noticeType === 'KYC').length;
const dormancy = this.bulkTargets.filter((t) => t.noticeType === 'DORMANCY').length;
return [
{ label: 'Re-KYC notice (expiry, freeze or inoperative dates)', count: kyc },
{ label: 'Account notice (inactive, dormant or closed)', count: dormancy }
].filter((x) => x.count > 0);
}
get bulkSendDisabled(): boolean {
return (
this.bulkSending ||
!this.bulkNotes.trim() ||
this.bulkTargets.length === 0 ||
this.bulkTargets.length > this.bulkLimit
);
}
openBulk(): void {
this.ensureNoticeInfo(this.selectedTargets().map((t) => t.clientId));
this.bulkSkipDone = true;
this.bulkChannel = 'SMS';
this.bulkNotes = '';
this.bulkOfficer = '';
this.bulkResults = [];
this.bulkDone = false;
this.bulkSendAt = '';
this.bulkError = '';
this.bulkQueued = null;
this.bulkOpen = true;
}
closeBulk(): void {
if (this.bulkSending) {
return;
}
this.bulkOpen = false;
}
/** Hands the selected customers to the queue; the queue job sends them in the background. */
sendBulk(): void {
if (this.bulkSendDisabled) {
return;
}
this.bulkSending = true;
this.clientsService
.enqueueKycNotices({
items: this.bulkTargets.map((t) => ({
clientId: t.clientId,
noticeType: t.noticeType,
channel: this.bulkChannel,
accountNo: t.account,
stageLabel: t.noticeType === 'DORMANCY' ? 'Account notice' : 'Re-KYC notice'
})),
notes: this.bulkNotes.trim(),
officerName: this.bulkOfficer.trim() || undefined,
sendAt: this.bulkSendAt || undefined
})
.subscribe({
next: (r) => {
this.bulkSending = false;
this.bulkDone = true;
this.bulkQueued = r;
this.alertSelected.clear();
this.loadAlertNotices();
this.loadQueue();
},
error: (err) => {
this.bulkSending = false;
this.bulkError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not queue the notices.';
}
});
}
// ── Notification scheduler ─────────────────────────────────────────────────
/**
* The escalation ladder an officer works down for each customer. The first three transmit; the rest
* are things the branch does and records, so they are listed but never sent from here.
*/
readonly schedulerLadder: { key: string; label: string; sends: boolean; notice?: number }[] = [
{ key: 'SMS', label: 'SMS', sends: true, notice: 1 },
{ key: 'EMAIL', label: 'Email', sends: true, notice: 2 },
{ key: 'WHATSAPP', label: 'WhatsApp', sends: true, notice: 3 },
{ key: 'PHONE_CALL', label: 'Phone Call', sends: false },
{ key: 'BRANCH_VISIT', label: 'Branch Visit', sends: false },
{ key: 'FIELD_VISIT', label: 'Field Visit', sends: false }
];
schedScope: 'ALL' | 'EXPIRING' | 'LAPSED' = 'ALL';
schedSearch = '';
schedSelected = new Set<number>();
schedOpen = false;
schedOfficer = '';
schedNote = '';
schedSending = false;
schedDone = false;
schedError = '';
schedQueued: { queued: number; skipped: number; unreachable: number } | null = null;
schedResults: { name: string; account: string; step: string; ok: boolean; message: string }[] = [];
/** One line per customer with the ladder worked out from the outreach already on record. */
get schedulerRows(): {
row: KycExpiryRow;
steps: { label: string; state: 'done' | 'next' | 'todo' }[];
next: { key: string; label: string; sends: boolean; notice?: number } | null;
}[] {
const needle = this.schedSearch.trim().toLowerCase();
const source =
this.schedScope === 'EXPIRING' ? this.expiringRows : this.schedScope === 'LAPSED' ? this.expiryRows : [
...this.expiryRows,
...this.expiringRows
];
return source
.filter(
(r) => !needle || r.clientName.toLowerCase().includes(needle) || r.accountNo.toLowerCase().includes(needle)
)
.map((row) => {
// A send that failed does not count: the customer was not reached, so the same rung is due again.
const done = (s: { key: string; sends: boolean }) =>
row.contacts.some((c) => c.channel === s.key && (c.delivered || !s.sends));
let nextFound = false;
let next: { key: string; label: string; sends: boolean; notice?: number } | null = null;
const steps = this.schedulerLadder.map((s) => {
if (done(s)) {
return { label: s.label, state: 'done' as const };
}
if (!nextFound) {
nextFound = true;
next = s;
return { label: s.label, state: 'next' as const };
}
return { label: s.label, state: 'todo' as const };
});
return { row, steps, next };
});
}
/** Customers whose next rung is a notice the system can send now. */
get schedulerDueCount(): number {
return this.schedulerRows.filter((r) => r.next?.sends).length;
}
get schedulerNeverContacted(): number {
return this.schedulerRows.filter((r) => r.row.contactCount === 0).length;
}
toggleSched(clientId: number): void {
if (this.schedSelected.has(clientId)) {
this.schedSelected.delete(clientId);
} else {
this.schedSelected.add(clientId);
}
}
/** The customer list opens on request; nothing is listed for notice until the officer asks. */
schedShow = false;
get schedAllSelected(): boolean {
const rows = this.schedulerRows.filter((r) => r.next?.sends);
return rows.length > 0 && rows.every((r) => this.schedSelected.has(r.row.clientId));
}
toggleSchedAll(): void {
const rows = this.schedulerRows.filter((r) => r.next?.sends);
if (this.schedAllSelected) {
rows.forEach((r) => this.schedSelected.delete(r.row.clientId));
} else {
rows.forEach((r) => this.schedSelected.add(r.row.clientId));
}
}
/** The ticked customers, each with the notice that is due for them. */
get schedTargets(): KycManagementComponent['schedulerRows'] {
return this.schedulerRows.filter((r) => r.next?.sends && this.schedSelected.has(r.row.clientId));
}
get schedSendDisabled(): boolean {
return this.schedSending || this.schedTargets.length === 0 || this.schedTargets.length > this.bulkLimit;
}
openSched(): void {
this.schedOfficer = '';
this.schedNote = '';
this.schedResults = [];
this.schedDone = false;
this.schedError = '';
this.schedQueued = null;
this.schedOpen = true;
}
/** Row-level shortcut: dispatch just this customer's next notice. */
dispatchOne(clientId: number): void {
this.schedSelected = new Set([clientId]);
this.openSched();
}
closeSched(): void {
if (!this.schedSending) {
this.schedOpen = false;
}
}
/** Queues each selected customer's next notice; the queue job sends them in the background. */
dispatchScheduled(): void {
if (this.schedSendDisabled) {
return;
}
this.schedSending = true;
this.clientsService
.enqueueKycNotices({
items: this.schedTargets.map((t) => ({
clientId: t.row.clientId,
noticeType: 'KYC',
channel: t.next!.key,
accountNo: t.row.accountNo,
stageLabel: 'Re-KYC notice ' + (t.next!.notice ?? '') + ' (' + t.next!.label + ')'
})),
notes: this.schedNote.trim() || 'Re-KYC reminder — scheduled notice',
officerName: this.schedOfficer.trim() || undefined
})
.subscribe({
next: (r) => {
this.schedSending = false;
this.schedDone = true;
this.schedQueued = r;
this.schedSelected.clear();
this.loadQueue();
},
error: (err) => {
this.schedSending = false;
this.schedError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not queue the notices.';
}
});
}
// ── Wording for channels and delivery outcomes, used wherever a notice is described ──
readonly auditChannelLabels: Record<string, string> = {
SMS: 'SMS',
EMAIL: 'Email',
WHATSAPP: 'WhatsApp',
PHONE_CALL: 'Phone Call',
BRANCH_VISIT: 'Branch Visit',
FIELD_VISIT: 'Field Visit'
};
readonly auditStatusLabels: Record<string, string> = {
SENT: 'Delivered',
FAILED: 'Failed',
SKIPPED_NO_DESTINATION: 'No contact detail',
PROVIDER_NOT_CONFIGURED: 'Gateway not set up',
NOT_APPLICABLE: 'Recorded'
};
// ── Audit inventory: every notice on record, a day at a time ───────────────
/** One line per day, worked out by the server so a very large log is never loaded whole. */
auditDays: {
date: string;
notices: number;
customers: number;
notifiedCustomers: number;
delivered: number;
failed: number;
noContact: number;
notConfigured: number;
recorded: number;
}[] = [];
auditFrom = '';
auditTo = '';
auditChannel = '';
auditStatus = '';
auditSearch = '';
auditLoading = false;
auditError = '';
/** The day open in the popup, and the page of its notices on screen. */
auditDay: string | null = null;
auditDayRows: {
id: number;
clientId: number;
clientName: string;
accountNo: string;
channel: string;
notes: string;
officerName: string;
deliveryStatus: string;
destinationMasked: string;
provider: string;
providerReference: string;
failureReason: string;
messageBody: string;
contactedAt: string;
}[] = [];
auditDayTotal = 0;
auditDayPage = 0;
readonly auditPageSize = 50;
auditDayLoading = false;
auditDayError = '';
auditExpanded: number | null = null;
get auditNoticeTotal(): number {
return this.auditDays.reduce((n, d) => n + d.notices, 0);
}
/** Leave out attempts that went nowhere (no mobile / email, no gateway) unless the officer asks for them. */
auditShowAll = false;
auditView: 'days' | 'customers' = 'days';
auditCustomers: {
clientId: number;
clientName: string;
accountNo: string;
notices: number;
delivered: number;
failed: number;
notSent: number;
recorded: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
}[] = [];
auditCustTotal = 0;
auditCustPage = 0;
auditCustLoading = false;
private auditFilter(): {
from?: string;
to?: string;
channel?: string;
status?: string;
search?: string;
real?: boolean;
} {
return {
from: this.auditFrom,
to: this.auditTo,
channel: this.auditChannel,
status: this.auditStatus,
search: this.auditSearch.trim(),
real: !this.auditShowAll
};
}
setAuditView(view: 'days' | 'customers'): void {
this.auditView = view;
this.leaveAuditDay();
if (view === 'customers') {
this.auditCustPage = 0;
this.loadAuditCustomers();
}
}
loadAuditCustomers(): void {
this.auditCustLoading = true;
this.clientsService
.getKycContactAuditCustomers({
...this.auditFilter(),
offset: this.auditCustPage * this.auditPageSize,
limit: this.auditPageSize
})
.subscribe({
next: (r) => {
this.auditCustomers = r?.rows || [];
this.auditCustTotal = r?.total || 0;
this.auditCustLoading = false;
},
error: () => {
this.auditCustLoading = false;
this.auditError = 'Could not load the customers.';
}
});
}
auditCustPageCount(): number {
return Math.max(1, Math.ceil(this.auditCustTotal / this.auditPageSize));
}
moveAuditCustPage(by: number): void {
this.auditCustPage = Math.min(Math.max(this.auditCustPage + by, 0), this.auditCustPageCount() - 1);
this.loadAuditCustomers();
}
loadAudit(): void {
this.auditLoading = true;
this.auditError = '';
this.clientsService.getKycContactAuditDays(this.auditFilter()).subscribe({
next: (days) => {
this.auditDays = days || [];
this.auditLoading = false;
},
error: () => {
this.auditLoading = false;
this.auditError = 'Could not load the notice log.';
}
});
}
/** Totals across the days on screen. Customers is a sum of each day's, so a customer reached on two days counts twice. */
get auditTotals(): {
notifiedCustomers: number;
customers: number;
notices: number;
delivered: number;
failed: number;
noContact: number;
recorded: number;
} {
const sum = (pick: (d: KycManagementComponent['auditDays'][number]) => number) =>
this.auditDays.reduce((n, d) => n + pick(d), 0);
return {
notifiedCustomers: sum((d) => d.notifiedCustomers ?? 0),
customers: sum((d) => d.customers),
notices: sum((d) => d.notices),
delivered: sum((d) => d.delivered),
failed: sum((d) => d.failed),
noContact: sum((d) => d.noContact + d.notConfigured),
recorded: sum((d) => d.recorded)
};
}
/** Apply the filters: back to the day list, freshly counted. */
applyAudit(): void {
this.leaveAuditDay();
this.loadAudit();
if (this.auditView === 'customers') {
this.auditCustPage = 0;
this.loadAuditCustomers();
}
}
/** The last N days up to today. Notices are stamped with the real date, so this uses the computer's date. */
setAuditRange(days: number): void {
const iso = (d: Date) =>
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - (days - 1));
this.auditFrom = iso(from);
this.auditTo = iso(to);
this.applyAudit();
}
resetAudit(): void {
this.auditFrom = '';
this.auditTo = '';
this.auditChannel = '';
this.auditStatus = '';
this.auditSearch = '';
this.applyAudit();
}
openAuditDay(date: string): void {
this.auditDay = date;
history.pushState({ ...history.state, kmAuditDay: date }, '');
this.auditDayPage = 0;
this.auditExpanded = null;
this.loadAuditDay();
}
closeAuditDay(): void {
this.leaveAuditDay();
}
/** The popup applies the same channel, status and search as the day list, narrowed to its one day. */
loadAuditDay(): void {
if (!this.auditDay) {
return;
}
this.auditDayLoading = true;
this.auditDayError = '';
this.clientsService
.getKycContactAudit({
...this.auditFilter(),
from: this.auditDay,
to: this.auditDay,
offset: this.auditDayPage * this.auditPageSize,
limit: this.auditPageSize
})
.subscribe({
next: (r) => {
this.auditDayRows = r?.rows || [];
this.auditDayTotal = r?.total || 0;
this.auditDayLoading = false;
},
error: () => {
this.auditDayLoading = false;
this.auditDayError = 'Could not load the notices for this day.';
}
});
}
auditDayPageCount(): number {
return Math.max(1, Math.ceil(this.auditDayTotal / this.auditPageSize));
}
moveAuditDayPage(by: number): void {
this.auditDayPage = Math.min(Math.max(this.auditDayPage + by, 0), this.auditDayPageCount() - 1);
this.auditExpanded = null;
this.loadAuditDay();
}
auditStatusClass(status: string): string {
return status === 'SENT'
? 'km-b-gr'
: status === 'NOT_APPLICABLE'
? 'km-b-gy'
: status === 'FAILED'
? 'km-b-re'
: 'km-b-am';
}
/** Fetches notices a page at a time for a workbook, up to a cap so one click cannot pull a whole year. */
private async fetchAuditRows(
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string },
cap: number
): Promise<{ rows: KycManagementComponent['auditDayRows']; total: number }> {
const rows: KycManagementComponent['auditDayRows'] = [];
let total = 0;
while (rows.length < cap) {
const page = await firstValueFrom(
this.clientsService.getKycContactAudit({
...filter,
offset: rows.length,
limit: Math.min(500, cap - rows.length)
})
);
total = page?.total || 0;
const batch = page?.rows || [];
rows.push(...batch);
if (!batch.length || rows.length >= total) {
break;
}
}
return { rows, total };
}
private readonly auditHeaders = [
'#',
'Date & time',
'Customer',
'Account',
'Channel',
'Delivery',
'Sent to',
'Reference',
'Officer',
'Note',
'Failure reason',
'Message sent'
];
private readonly auditWidths = [
6,
20,
28,
20,
14,
18,
22,
22,
22,
40,
30,
80
];
private readonly auditDayHeaders = [
'Date',
'Customers notified',
'Customers tried',
'Notices',
'Delivered',
'Failed',
'No contact detail',
'Gateway not set up',
'Recorded (call / visit)'
];
private auditNoticeSheetRows(rows: KycManagementComponent['auditDayRows']): (string | number | null)[][] {
return rows.map((r, i) => [
i + 1,
(r.contactedAt || '').replace('T', ' ').slice(0, 19),
r.clientName,
r.accountNo,
this.auditChannelLabels[r.channel] || r.channel,
this.auditStatusLabels[r.deliveryStatus] || r.deliveryStatus,
r.destinationMasked || '',
r.providerReference || '',
r.officerName || '',
r.notes || '',
r.failureReason || '',
r.messageBody || ''
]);
}
private auditDaySheetRows(): (string | number | null)[][] {
return this.auditDays.map((d) => [
d.date,
d.notifiedCustomers ?? 0,
d.customers,
d.notices,
d.delivered,
d.failed,
d.noContact,
d.notConfigured,
d.recorded
]);
}
/** The day list as a workbook: one line per day with its counts. */
async exportAudit(): Promise<void> {
if (!this.auditDays.length) {
this.toast('There are no notices to export.');
return;
}
try {
await exportWorkbook(
[
{
sheetName: 'Notices per day',
title: 'KYC & account notices sent to customers — per day',
subtitle: this.auditNoticeTotal + ' notice(s)',
headers: this.auditDayHeaders,
rows: this.auditDaySheetRows(),
widths: [
14,
18,
16,
12,
12,
12,
20,
20,
24
]
}
],
'KYC-Notices-Per-Day-' + businessTodayIso()
);
this.toast('Downloaded.');
} catch {
this.toast('Could not build the file. Please try again.');
}
}
/** Every notice of the day open in the popup, with the exact text sent. */
async exportAuditDay(): Promise<void> {
if (!this.auditDay) {
return;
}
try {
const { rows, total } = await this.fetchAuditRows(
{ ...this.auditFilter(), from: this.auditDay, to: this.auditDay },
5000
);
await exportWorkbook(
[
{
sheetName: 'Notice log',
title: 'Notices sent on ' + this.auditDay,
subtitle: rows.length + (rows.length < total ? ' of ' + total : '') + ' notice(s)',
headers: this.auditHeaders,
rows: this.auditNoticeSheetRows(rows),
widths: this.auditWidths
}
],
'KYC-Notices-' + this.auditDay
);
this.toast(
rows.length < total ? 'Downloaded the first ' + rows.length + ' of ' + total + ' notices.' : 'Downloaded.'
);
} catch {
this.toast('Could not build the file. Please try again.');
}
}
/** The workbook handed to an inspector: a summary, the per-day figures, then the notices with the exact text sent. */
async exportRbiReport(): Promise<void> {
if (!this.auditDays.length) {
this.toast('There are no notices to report.');
return;
}
const sum = (pick: (d: KycManagementComponent['auditDays'][number]) => number) =>
this.auditDays.reduce((n, d) => n + pick(d), 0);
const period =
(this.auditFrom || this.auditDays[this.auditDays.length - 1].date) +
' to ' +
(this.auditTo || this.auditDays[0].date);
const summary: (string | number | null)[][] = [
[
'Notices on record',
sum((d) => d.notices)
],
[
'Delivered',
sum((d) => d.delivered)
],
[
'Failed',
sum((d) => d.failed)
],
[
'No contact detail on file',
sum((d) => d.noContact)
],
[
'Gateway not set up',
sum((d) => d.notConfigured)
],
[
'Recorded (call / visit)',
sum((d) => d.recorded)
],
[
'Days with notices',
this.auditDays.length
]
];
try {
const { rows, total } = await this.fetchAuditRows(this.auditFilter(), 2000);
await exportWorkbook(
[
{ sheetName: 'Summary', title: 'Customer notice audit — summary', subtitle: 'Period: ' + period, headers: [
'Measure',
'Count'
], rows: summary, widths: [
40,
14
] },
{
sheetName: 'Per day',
title: 'Customer notice audit — per day',
subtitle: 'Period: ' + period,
headers: this.auditDayHeaders,
rows: this.auditDaySheetRows(),
widths: [
14,
18,
16,
12,
12,
12,
20,
20,
24
]
},
{
sheetName: 'Notice log',
title: 'Customer notice audit — notices',
subtitle:
'Period: ' + period + (rows.length < total ? ' · newest ' + rows.length + ' of ' + total + ' shown' : ''),
headers: this.auditHeaders,
rows: this.auditNoticeSheetRows(rows),
widths: this.auditWidths
}
],
'RBI-KYC-Notice-Audit-' + businessTodayIso()
);
this.toast('RBI audit report downloaded.');
} catch {
this.toast('Could not build the report. Please try again.');
}
}
/** Alert Dashboard: the list on screen, as a workbook. */
async exportAlerts(): Promise<void> {
const rows = this.alertList;
if (!rows.length) {
this.toast('Pick a card or a filter first — there is no list to export.');
return;
}
try {
await exportWorkbook(
[
{
sheetName: 'Accounts',
title: this.alertFilter ? this.alertLabels[this.alertFilter] : 'Accounts needing attention',
subtitle: rows.length + ' account(s) as on ' + businessTodayIso(),
headers: [
'Customer',
'Account',
'Branch',
'State',
'Date',
'Days',
'Balance',
'Next stage',
'Next date'
],
rows: rows.map((r) => [
r.name,
r.account,
r.branch,
r.stage,
r.since,
r.days + ' ' + r.daysLabel,
r.balance,
r.next,
r.nextDate || ''
]),
widths: [
28,
22,
18,
24,
14,
20,
16,
30,
14
]
}
],
'KYC-Alerts-' + businessTodayIso()
);
this.toast('Downloaded.');
} catch {
this.toast('Could not build the file. Please try again.');
}
}
// ── Notice queue (what officers submitted, and how it went) ───────────────
queueRows: {
id: number;
clientId: number;
clientName: string;
accountNo: string;
noticeType: string;
channel: string;
stageLabel: string;
status: string;
attempts: number;
resultMessage: string;
scheduledFor: string;
createdAt: string;
processedAt: string;
}[] = [];
queueFilter = '';
queueLoading = false;
queueError = '';
private queueTimer: any;
get queueCounts(): Record<string, number> {
const counts: Record<string, number> = { PENDING: 0, SENDING: 0, SENT: 0, FAILED: 0, CANCELLED: 0 };
this.queueRows.forEach((r) => (counts[r.status] = (counts[r.status] || 0) + 1));
return counts;
}
get queueView(): KycManagementComponent['queueRows'] {
return this.queueFilter ? this.queueRows.filter((r) => r.status === this.queueFilter) : this.queueRows;
}
/** The queue grouped by the day it was submitted, newest day first, with a count per outcome. */
get queueGroups(): {
date: string;
rows: KycManagementComponent['queueRows'];
customers: number;
waiting: number;
sent: number;
failed: number;
cancelled: number;
}[] {
const byDate = new Map<string, KycManagementComponent['queueRows']>();
for (const r of this.queueView) {
const day = (r.createdAt || '').slice(0, 10);
byDate.set(day, [
...(byDate.get(day) || []),
r
]);
}
return [...byDate.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(
([
date,
rows
]) => ({
date,
rows,
customers: new Set(rows.map((r) => r.clientId)).size,
waiting: rows.filter((r) => r.status === 'PENDING' || r.status === 'SENDING').length,
sent: rows.filter((r) => r.status === 'SENT').length,
failed: rows.filter((r) => r.status === 'FAILED').length,
cancelled: rows.filter((r) => r.status === 'CANCELLED').length
})
);
}
/** The day whose notices are open in the popup. */
queueDetailDate: string | null = null;
get queueDetail(): KycManagementComponent['queueGroups'][number] | null {
return this.queueDetailDate ? this.queueGroups.find((g) => g.date === this.queueDetailDate) || null : null;
}
openQueueDate(date: string): void {
this.queueDetailDate = date;
}
/** Notices that could not be delivered; the officer reaches these customers by hand. */
queueFailed(d: { rows: any[] }): any[] {
return d.rows.filter((q) => q.status === 'FAILED');
}
/** Everything else that was queued that day. */
queueOther(d: { rows: any[] }): any[] {
return d.rows.filter((q) => q.status !== 'FAILED');
}
closeQueueDate(): void {
delete this.pg['queue'];
this.queueDetailDate = null;
}
loadQueue(): void {
this.queueLoading = true;
this.clientsService.getKycNoticeQueue().subscribe({
next: (rows) => {
this.queueRows = rows || [];
this.queueLoading = false;
this.queueError = '';
},
error: () => {
this.queueLoading = false;
this.queueError = 'Could not load the notice queue.';
}
});
}
actOnQueued(id: number, command: 'cancel' | 'retry'): void {
this.clientsService.actOnKycNotice(id, command).subscribe({
next: () => this.loadQueue(),
error: (err) =>
this.toast(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'That could not be done.'
)
});
}
/** The queue moves on its own while the scheduler tab is open, so keep it fresh. */
private startQueuePolling(): void {
this.stopQueuePolling();
this.loadQueue();
this.queueTimer = setInterval(() => this.loadQueue(), 10000);
}
private stopQueuePolling(): void {
if (this.queueTimer) {
clearInterval(this.queueTimer);
this.queueTimer = null;
}
}
ngOnDestroy(): void {
this.stopQueuePolling();
}
loadStatusData(): void {
this.statusLoading = true;
this.statusError = '';
const today = businessTodayIso();
forkJoin({
lapsed: this.clientsService.getLapsedKyc().pipe(
catchError(() => {
this.statusError = 'Could not load the lapsed KYC list.';
return of([] as any[]);
})
),
expiring: this.clientsService.getExpiringKyc(30).pipe(
catchError(() => {
this.statusError = this.statusError || 'Could not load the list of KYCs expiring soon.';
return of([] as any[]);
})
),
dormant: this.clientsService.getDormantAccounts().pipe(
catchError(() => {
this.statusError = this.statusError || 'Could not load the dormancy list.';
return of([] as any[]);
})
)
}).subscribe(({ lapsed, dormant, expiring }) => {
try {
this.dormancyRows = sortByIdle(
(dormant || []).map((a) => this.mapDormant(a)).filter((r): r is DormancyRow => r !== null)
);
} catch {
this.dormancyRows = [];
this.statusError = this.statusError || 'Could not read the dormancy list.';
}
this.dormancyTotals = dormancySummary(this.dormancyRows, today);
const mapKyc = (list: any[]) =>
(list || [])
.map((r: any) => ({
clientId: r?.clientId,
clientName: r?.clientDisplayName || '—',
accountNo: r?.clientAccountNo || '',
branchCode: r?.clientOfficeName || '',
riskCategory: r?.riskCategory ?? 1,
expiryDate: toIsoDate(r?.expiryDate),
mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '')
}))
.filter((c) => !!c.expiryDate);
const clients = mapKyc(lapsed);
const soon = mapKyc(expiring);
const everyone = [
...clients,
...soon
];
const finish = (histories: KycContactRow[][]) => {
this.expiryRows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i] || [], today)));
this.expiringRows = soon
.map((c, i) => buildExpiryRow(c, histories[clients.length + i] || [], today))
.sort((a, b) => a.expiryDate.localeCompare(b.expiryDate));
this.expiryTotals = expirySummary(this.expiryRows);
this.statusLoading = false;
// The list that is open needs each customer's contact details.
this.loadReachForList();
};
if (!everyone.length) {
finish([]);
return;
}
forkJoin(
everyone.map((c) =>
this.clientsService.getKycContactLog(c.clientId).pipe(
map((logs: any[]) => (logs || []).map((l) => this.mapAuditContact(l))),
catchError(() => of([] as KycContactRow[]))
)
)
).subscribe({
next: finish,
error: () => finish([])
});
});
}
private mapAuditContact(log: any): KycContactRow {
const status = log?.deliveryStatus || 'NOT_APPLICABLE';
return {
date: toIsoDate(log?.contactedOn || log?.createdOn),
channel: log?.channel || '',
channelLabel: channelLabel(log?.channel || ''),
notes: log?.notes || '',
officer: log?.officerName || log?.loggedByUsername || '—',
deliveryStatus: status,
delivered: status === 'SENT',
destinationMasked: log?.destinationMasked || ''
};
}
private mapDormant(a: any): DormancyRow | null {
const stage = stageFromSubStatus(Number(a?.subStatus));
if (!stage) {
return null;
}
const lastTxn = toIsoDate(a?.lastCustomerTransactionDate);
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return {
accountType: a?.accountType || 'SAVINGS',
accountId: a?.accountId,
accountNo: a?.accountNo || '—',
clientId: a?.clientId,
clientName: a?.clientName || '—',
branchCode: a?.officeName || '',
productName: a?.productName || '—',
balance: Number(a?.balance ?? 0),
lastCustomerTxnDate: lastTxn,
daysIdle: Number(a?.daysIdle ?? 0),
stage,
subStatus: Number(a?.subStatus ?? 0),
nextStageDate: next.date,
nextStageLabel: next.label,
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
escheatedOn: toIsoDate(a?.closedOnDate) || undefined,
remittedOn: toIsoDate(a?.deafRemittedOn) || undefined,
deafReference: a?.deafReference || undefined,
refundedOn: toIsoDate(a?.deafRefundedOn) || undefined,
mobile: a?.mobileNo || ''
};
}
ngOnInit(): void {
this.loadExceptions();
this.loadStatusData();
this.loadAudit();
}
loadExceptions(): void {
this.clientsService.getKycExceptions(false).subscribe({
next: (data) => {
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
this.exceptionRows = this.pendingExceptions;
this.loadContactHistories(this.pendingExceptions);
},
error: (err) => {
console.error('Failed to load active exceptions', err);
this.pendingExceptions = [];
this.exceptionRows = [];
}
});
this.clientsService.getKycExceptions(true).subscribe({
next: (data) => {
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
this.loadContactHistories(this.resolvedExceptions);
},
error: (err) => {
console.error('Failed to load resolved exceptions', err);
this.resolvedExceptions = [];
}
});
}
private mapException(item: any, resolved: boolean): KmClient {
return {
id: item.clientId?.toString(),
av: (item.clientDisplayName || 'Client')
.split(' ')
.map((n: string) => n[0])
.join('')
.substring(0, 2)
.toUpperCase(),
name: item.clientDisplayName,
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
account: item.clientAccountNo || `CLI-${item.clientId}`,
state: 'alert',
risk: item.riskCategoryCode?.toLowerCase() || 'low',
riskLabel: item.riskCategory || 'Low Risk',
expiry: 'Pending',
daysLeft: 30,
kyc: {
type: item.kycType || 'Individual',
initiated: item.initiatedOn || '—',
lastVerified: item.verifiedOn || '—',
validity: '—',
pan: item.panNumber || '—',
aadhaar: item.aadhaarNumberMasked || '—',
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
panLink: 'Not Linked'
},
contact: {
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
// here — it's why an SMS or Email channel will be unavailable for this customer.
mobile: item.clientMobileMasked || 'Not on file',
email: item.clientEmailMasked || 'Not on file',
address: 'Client Address, Bhopal MP 462002',
branch: item.clientOfficeName || 'Bhopal Main'
},
notices: [],
noticesSent: 0,
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
actionTaken: resolved ? ['KYC Exception resolved'] : [],
evidence: null,
isException: true,
// No invented default — a record whose reason was never stored (or was cleared by the old
// resolve path) must not be relabelled as a PAN failure it may never have had.
exceptionReason: item.exceptionReason || 'Reason not recorded',
exceptionRemarks: item.remarks || '',
exceptionDate: item.initiatedOn || '—',
exceptionResolved: resolved,
exceptionResolvedOn: item.exceptionResolvedOn || '—',
contactHistory: [],
kycId: item.id?.toString(),
hasMobile: item.hasMobile === true,
hasEmail: item.hasEmail === true
};
}
/**
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
* one row leaves that row's history empty rather than blanking the whole grid.
*/
private loadContactHistories(rows: KmClient[]): void {
for (const row of rows) {
if (!row.id) continue;
this.clientsService.getKycContactLog(row.id).subscribe({
next: (logs) => {
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
},
error: (err) => {
console.error(`Failed to load contact log for client ${row.id}`, err);
}
});
}
}
get notYetContactedCount(): number {
return this.exceptionRows.filter((c) => !c.contactHistory || c.contactHistory.length === 0).length;
}
get panMismatchesCount(): number {
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('PAN')).length;
}
get aadhaarFailuresCount(): number {
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length;
}
showSection(section: 'alerts' | 'status' | 'notif' | 'audit' | 'exceptions' | 'expiry' | 'dormancy'): void {
this.activeSection = section;
if (section === 'alerts') {
// Notices go out in the background while another tab is open; ask again so "Notified" is not out of date.
this.fetchNoticeInfo([...new Set(this.alertListBase.map((r) => r.clientId))]);
}
if (section === 'audit' && !this.auditDays.length) {
this.loadAudit();
}
if (section === 'notif') {
this.startQueuePolling();
} else {
this.stopQueuePolling();
}
}
openContactDialog(client: KmClient): void {
this.current = client;
this.contactNoticeType = '';
this.contactPreview = null;
this.contactChannel = 'PHONE_CALL';
this.contactNotes = '';
this.contactError = '';
this.contactSaving = false;
this.contactOfficer = '';
this.contactDialogOpen = true;
}
closeContactDialog(): void {
this.contactDialogOpen = false;
}
/** The channel currently picked in the dialog. */
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
return this.contactChannels.find((c) => c.value === this.contactChannel);
}
/** True when saving will transmit a notice to the customer, not merely record one. */
get selectedChannelSends(): boolean {
return this.selectedChannel?.sends === true;
}
/**
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
* the officer isn't left reading a failure they could have seen up front.
*/
get selectedChannelUnavailable(): boolean {
const requires = this.selectedChannel?.requires;
if (!requires || !this.current) return false;
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
}
get contactSaveDisabled(): boolean {
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
}
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
get contactSaveLabel(): string {
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
}
saveContactLog(): void {
if (!this.current || this.contactSaveDisabled) return;
const client = this.current;
this.contactSaving = true;
this.contactError = '';
this.clientsService
.saveKycContactLog(client.id, {
channel: this.contactChannel,
notes: this.contactNotes.trim(),
officerName: this.contactOfficer.trim() || undefined,
noticeType: this.contactNoticeType || undefined
})
.subscribe({
next: (log: any) => {
this.contactSaving = false;
client.contactHistory = [
...(client.contactHistory || []),
this.mapContactLog(log)
];
// The request succeeding does not mean the customer was reached — a gateway failure comes
// back as HTTP 200 with delivered=false, so report what actually happened.
if (log?.platformSent && !log?.delivered) {
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
} else {
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
}
this.contactDialogOpen = false;
this.loadStatusData();
this.loadAlertNotices();
},
error: (err) => {
this.contactSaving = false;
// Keep the dialog open — the officer's notes are still in the textarea and would
// otherwise be lost.
this.contactError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not save the contact log. Please try again.';
}
});
}
private mapContactLog(log: any): KmContactLog {
return {
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
action: log?.channelLabel || log?.channel || 'Contact',
notes: log?.notes || '',
officer: (log?.officerName || '').split(' — ')[0],
platformSent: log?.platformSent,
delivered: log?.delivered,
deliveryStatus: log?.deliveryStatus,
deliveryMessage: log?.deliveryMessage,
destinationMasked: log?.destinationMasked
};
}
/**
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
*/
private formatLogDate(value: any): string {
const date = Array.isArray(value)
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
: new Date(value);
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
get isResolveValid(): boolean {
if (!this.resolveRemarks?.trim()) return false;
let anyActive = false;
if (this.showPanResolution && this.resolvePanActive) {
anyActive = true;
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
}
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
anyActive = true;
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
}
if (this.showGeneralResolution && this.resolveGeneralActive) {
anyActive = true;
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
}
return anyActive;
}
openResolveDialog(client: KmClient): void {
this.current = client;
const reason = client.exceptionReason || '';
this.showPanResolution = reason.includes('PAN');
this.showAadhaarResolution = reason.includes('Aadhaar');
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
this.resolvePanActive = this.showPanResolution;
this.resolveAadhaarActive = this.showAadhaarResolution;
this.resolveGeneralActive = this.showGeneralResolution;
this.resolvePanNumber = '';
this.resolveAadhaarNumber = '';
this.resolveGeneralDocumentType = 'OVD';
this.resolveGeneralDocumentNumber = '';
this.resolveRemarks = '';
this.resolveDialogOpen = true;
}
closeResolveDialog(): void {
this.resolveDialogOpen = false;
}
saveResolveKyc(): void {
if (!this.current || !this.current.kycId) return;
const clientId = this.current.id;
const kycId = this.current.kycId;
const today = new Date();
const day = today.getDate().toString().padStart(2, '0');
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const monthStr = months[today.getMonth()];
const year = today.getFullYear();
const dateStr = `${day} ${monthStr} ${year}`;
const resolvingPan = this.showPanResolution && this.resolvePanActive;
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
const remainsPan = this.showPanResolution && !this.resolvePanActive;
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
const isFullyResolved = !remainsPan && !remainsAadhaar;
const resolvedKycData: any = {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
};
if (isFullyResolved) {
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
// the audit record of why the exception was ever raised.
} else {
resolvedKycData.isException = true;
resolvedKycData.exceptionResolved = false;
const remainingReasons: string[] = [];
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
resolvedKycData.exceptionReason = remainingReasons.join(', ');
}
if (resolvingPan) {
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
resolvedKycData.panVerified = true;
}
if (resolvingAadhaar) {
const aadhaarRaw = this.resolveAadhaarNumber.trim();
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
resolvedKycData.aadhaarVerified = true;
}
if (resolvingGeneral) {
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// exceptionReason preserved — see the fully-resolved branch above.
}
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
next: () => {
const msg = isFullyResolved
? `KYC Exception fully resolved and completed for ${this.current?.name}`
: `KYC Exception partially resolved for ${this.current?.name}`;
this.toast(msg);
this.loadExceptions();
this.resolveDialogOpen = false;
},
error: (err) => {
console.error('Failed to resolve exception', err);
this.toast('Failed to resolve exception. Please try again.');
}
});
}
toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
}
// ── 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(' ; ');
}
riskClass(risk: string): string {
return ({ low: 'b-gr', mid: 'b-am', high: 'b-re' } as Record<string, string>)[risk] || 'b-gy';
}
}