/** * 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 = { 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): 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(); 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 = {}; 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(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; noticesByStage?: Record; 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(); 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(); 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(); 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(); 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 = { SMS: 'SMS', EMAIL: 'Email', WHATSAPP: 'WhatsApp', PHONE_CALL: 'Phone Call', BRANCH_VISIT: 'Branch Visit', FIELD_VISIT: 'Field Visit' }; readonly auditStatusLabels: Record = { 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 { 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 { 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 { 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 { 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 { const counts: Record = { 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(); 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 { 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)[risk] || 'b-gy'; } }