2775 lines
90 KiB
TypeScript
2775 lines
90 KiB
TypeScript
/**
|
||
* 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';
|
||
}
|
||
}
|