- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports. - Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons. - Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars. - Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval. - Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names. - Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks. - Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
1221 lines
48 KiB
TypeScript
1221 lines
48 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
|
import { Router, ActivatedRoute } from '@angular/router';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
import { Observable, of } from 'rxjs';
|
|
import { map } from 'rxjs/operators';
|
|
|
|
/** Custom Services */
|
|
import { ClientsService } from '../clients.service';
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
|
import { familyAllowed, nomineeAllowed } from '../utils/client-rules';
|
|
|
|
/** Custom Components */
|
|
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
|
|
import { ClientAddressStepComponent } from '../client-stepper/client-address-step/client-address-step.component';
|
|
import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-address-step/client-india-address-step.component';
|
|
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
|
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
|
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
|
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
|
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
|
import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/contact-otp-block.component';
|
|
|
|
import { MatTabsModule } from '@angular/material/tabs';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
import { MatDivider } from '@angular/material/divider';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
|
import { toProperNameCase } from '../utils/name-case.util';
|
|
|
|
/**
|
|
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-edit-client',
|
|
templateUrl: './edit-client.component.html',
|
|
styleUrls: ['./edit-client.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatTabsModule,
|
|
MatIconModule,
|
|
MatCheckboxModule,
|
|
MatDivider,
|
|
FaIconComponent,
|
|
MatProgressSpinner,
|
|
MatTooltip,
|
|
ClientGeneralStepComponent,
|
|
ClientAddressStepComponent,
|
|
ClientIndiaAddressStepComponent,
|
|
ClientEntityKycStepComponent,
|
|
ClientDatatableStepComponent,
|
|
ClientKycStepComponent,
|
|
ClientPreviewStepComponent,
|
|
ClientFamilyMembersStepComponent,
|
|
ContactOtpBlockComponent
|
|
]
|
|
})
|
|
export class EditClientComponent {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private dialog = inject(MatDialog);
|
|
private clientsService = inject(ClientsService);
|
|
private settingsService = inject(SettingsService);
|
|
private snackBar = inject(MatSnackBar);
|
|
|
|
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
|
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
|
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
|
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
|
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
|
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
|
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
|
|
|
datatables: any = [];
|
|
legalFormType = 1;
|
|
selectedTabIndex = 0;
|
|
savingClient = false;
|
|
|
|
/** Track which tabs are in edit mode (false = read-only, true = editable) */
|
|
tabEditMode: Record<number, boolean> = {};
|
|
|
|
/** Flag: DOB was changed — requires re-KYC (PAN re-verification) */
|
|
dobChanged = false;
|
|
|
|
/**
|
|
* Dual-OTP contact-change verification state (mobileNo / emailAddress). Mirrors the
|
|
* name/DOB "detect change against snapshot, gate the save" pattern already established
|
|
* in this component (see `detectSensitiveFieldChanges()` / `flagReKycRequired()`) —
|
|
* extended here so a changed mobile/email must be OTP-verified on BOTH the old and new
|
|
* destination before Save is allowed. Payload param names sent to the backend PUT are
|
|
* `mobileOldOtpId` / `mobileNewOtpId` / `emailOldOtpId` / `emailNewOtpId` (added onto
|
|
* `clientData` in `saveCurrentTab()`'s General-tab branch) — kept per-field rather than
|
|
* one generic old/new pair so mobile AND email can both be changed (and independently
|
|
* OTP-verified) in a single save.
|
|
*/
|
|
oldMobileOtpId: number | null = null;
|
|
newMobileOtpId: number | null = null;
|
|
oldMobileVerified = false;
|
|
newMobileVerified = false;
|
|
|
|
// FIR exception flow properties for inaccessible old mobile number
|
|
oldMobileNotAccessible = false;
|
|
firFileName = '';
|
|
firDocumentId: number | null = null;
|
|
uploadingFir = false;
|
|
|
|
oldEmailOtpId: number | null = null;
|
|
newEmailOtpId: number | null = null;
|
|
oldEmailVerified = false;
|
|
newEmailVerified = false;
|
|
|
|
/** Client data and template (merged object from resolver) */
|
|
clientDataAndTemplate: any;
|
|
/** Client ID */
|
|
clientId: string;
|
|
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
|
|
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
|
|
hasPendingGeneralEdit = false;
|
|
|
|
constructor() {
|
|
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
|
this.clientDataAndTemplate = data.clientDataAndTemplate;
|
|
this.clientId = this.clientDataAndTemplate.id;
|
|
if (this.clientDataAndTemplate.legalForm) {
|
|
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
|
}
|
|
this.loadPendingGeneralEdit();
|
|
});
|
|
}
|
|
|
|
private loadPendingGeneralEdit(): void {
|
|
if (!this.clientId) return;
|
|
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
|
|
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
|
|
error: () => (this.hasPendingGeneralEdit = false)
|
|
});
|
|
}
|
|
|
|
/** Check if a tab is in read-only mode */
|
|
isTabReadOnly(tabIndex: number): boolean {
|
|
return !this.tabEditMode[tabIndex];
|
|
}
|
|
|
|
/** Enable editing for a specific tab */
|
|
enableTabEdit(tabIndex: number): void {
|
|
this.tabEditMode[tabIndex] = true;
|
|
}
|
|
|
|
/** Cancel editing — revert tab back to read-only */
|
|
cancelTabEdit(tabIndex: number): void {
|
|
this.tabEditMode[tabIndex] = false;
|
|
}
|
|
|
|
get clientGeneralForm() {
|
|
return this.clientGeneralStep?.createClientForm;
|
|
}
|
|
|
|
/**
|
|
* Constitution display name pulled from the live general step (preferred —
|
|
* stays in sync if the user changes constitution mid-edit) with a fallback
|
|
* to the resolver-provided snapshot. Drives the proprietorship-only nominee
|
|
* rule below.
|
|
*/
|
|
get constitutionLabel(): string {
|
|
return (
|
|
this.clientGeneralStep?.selectedConstitution?.name ||
|
|
this.clientDataAndTemplate?.clientNonPersonDetails?.constitution?.name ||
|
|
''
|
|
);
|
|
}
|
|
|
|
/** Whether the Family/Nominee tab should appear at all. */
|
|
get showFamilyTab(): boolean {
|
|
return nomineeAllowed(this.legalFormType, this.constitutionLabel);
|
|
}
|
|
|
|
/** True when the tab should render in nominee-only mode (family rows hidden). */
|
|
get familyTabNomineeOnly(): boolean {
|
|
return !familyAllowed(this.legalFormType, this.constitutionLabel) && this.showFamilyTab;
|
|
}
|
|
|
|
/** Total tab count varies by legal form + constitution.
|
|
* Individual: General + KYC + Address + Family + Preview = 5
|
|
* Entity (proprietorship): General + KYC + S&S + Address + Nominees + Preview = 6
|
|
* Entity (non-proprietor): General + KYC + S&S + Address + Preview = 5
|
|
*/
|
|
get totalTabs(): number {
|
|
let count = 4; // General + KYC + Address + Preview
|
|
if (this.legalFormType === 2) count++; // S&S only for entities
|
|
if (this.showFamilyTab) count++;
|
|
return count;
|
|
}
|
|
|
|
/** Get constitution type from general step for entity KYC */
|
|
getConstitutionType(): string {
|
|
return this.clientGeneralStep?.createClientForm?.get('clientNonPersonDetails.constitutionId')?.value || '';
|
|
}
|
|
|
|
get client() {
|
|
const general = this.clientGeneralStep?.clientGeneralDetails || {};
|
|
return { ...general };
|
|
}
|
|
|
|
/**
|
|
* Live view of `detectSensitiveFieldChanges()` against the loaded snapshot — drives the
|
|
* "Verify Contact Change" OTP panel in the General tab template. Re-evaluated on every
|
|
* change-detection pass (cheap: a handful of form-control reads), so the panel appears/
|
|
* disappears as the user types, same as the existing re-KYC name/DOB detection.
|
|
*/
|
|
get contactChanges() {
|
|
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
|
}
|
|
|
|
/** Whether every OTP block required by the fields that actually changed has been verified
|
|
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
|
get contactVerificationComplete(): boolean {
|
|
const changes = this.contactChanges;
|
|
if (changes.mobileChanged) {
|
|
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
|
if (!isOldSatisfied || !this.newMobileVerified) {
|
|
return false;
|
|
}
|
|
}
|
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
|
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
|
if (checked !== undefined) {
|
|
this.oldMobileNotAccessible = checked;
|
|
}
|
|
if (this.oldMobileNotAccessible) {
|
|
this.oldMobileOtpId = null;
|
|
this.oldMobileVerified = false;
|
|
}
|
|
}
|
|
|
|
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
|
onFirFileSelected(event: any): void {
|
|
const file: File = event.target?.files?.[0];
|
|
if (!file) return;
|
|
this.firFileName = file.name;
|
|
this.uploadingFir = true;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', 'FIR - Lost Mobile Number');
|
|
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
|
|
|
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
|
next: (res: any) => {
|
|
this.uploadingFir = false;
|
|
this.firDocumentId = res?.resourceId || res?.id || null;
|
|
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
|
},
|
|
error: () => {
|
|
this.uploadingFir = false;
|
|
this.firDocumentId = null;
|
|
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
|
}
|
|
});
|
|
}
|
|
|
|
onOldMobileOtpState(state: ContactOtpState): void {
|
|
this.oldMobileOtpId = state.otpId;
|
|
this.oldMobileVerified = state.verified;
|
|
}
|
|
|
|
onNewMobileOtpState(state: ContactOtpState): void {
|
|
this.newMobileOtpId = state.otpId;
|
|
this.newMobileVerified = state.verified;
|
|
}
|
|
|
|
onOldEmailOtpState(state: ContactOtpState): void {
|
|
this.oldEmailOtpId = state.otpId;
|
|
this.oldEmailVerified = state.verified;
|
|
}
|
|
|
|
onNewEmailOtpState(state: ContactOtpState): void {
|
|
this.newEmailOtpId = state.otpId;
|
|
this.newEmailVerified = state.verified;
|
|
}
|
|
|
|
/** Reset all dual-OTP state — called once a General-tab save succeeds (the snapshot is
|
|
* updated to match, so the "Verify Contact Change" panel naturally disappears too). */
|
|
private resetContactVerificationState(): void {
|
|
this.oldMobileOtpId = null;
|
|
this.newMobileOtpId = null;
|
|
this.oldMobileVerified = false;
|
|
this.newMobileVerified = false;
|
|
this.oldMobileNotAccessible = false;
|
|
this.firFileName = '';
|
|
this.firDocumentId = null;
|
|
this.uploadingFir = false;
|
|
this.oldEmailOtpId = null;
|
|
this.newEmailOtpId = null;
|
|
this.oldEmailVerified = false;
|
|
this.newEmailVerified = false;
|
|
}
|
|
|
|
areFormvalids(): boolean {
|
|
if (!this.clientGeneralStep || !this.clientGeneralForm) {
|
|
return false;
|
|
}
|
|
let areValids = this.clientGeneralForm.valid;
|
|
if (this.clientGeneralStep.sanctionsBlocked) {
|
|
return false;
|
|
}
|
|
return areValids;
|
|
}
|
|
|
|
/**
|
|
* Per-tab form-validity gate for the Save & Next button. Mirrors the create
|
|
* flow — blocks navigation when the current tab has unfilled / invalid
|
|
* required fields. Tab order:
|
|
* 0 General
|
|
* 1 KYC & Documents
|
|
* 2 (non-individual) Scheme & Sector | (individual) Address
|
|
* 3 (non-individual) Address | (individual) Family
|
|
* 4 (non-individual) Family | Preview / Submit
|
|
*/
|
|
get currentStepValid(): boolean {
|
|
if (this.savingClient) return false;
|
|
const idx = this.selectedTabIndex;
|
|
|
|
if (idx === 0) {
|
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
|
if (!this.clientGeneralForm.valid) return false;
|
|
// Dual-OTP gate: a changed mobile/email must be verified on both old & new
|
|
// destinations before Save is allowed — mirrors the re-KYC gate below it.
|
|
return this.contactVerificationComplete;
|
|
}
|
|
if (idx === 1) {
|
|
const kf = this.clientKycStep?.kycForm;
|
|
return !kf || kf.valid;
|
|
}
|
|
if (this.legalFormType === 2) {
|
|
if (idx === 2) return true; // Scheme & Sector — opt-in
|
|
if (idx === 3) return this.isAddressStepValid();
|
|
if (idx === 4) return true; // Family — not applicable to non-individual entities
|
|
} else {
|
|
if (idx === 2) return this.isAddressStepValid();
|
|
if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Mother is required, and either Father or a Caretaker, for individual clients. */
|
|
private isFamilyStepValid(): boolean {
|
|
const step = this.clientFamilyMembersStep;
|
|
return !step || step.familyRelationshipRulesMet;
|
|
}
|
|
|
|
private isAddressStepValid(): boolean {
|
|
const step = this.clientIndiaAddressStep;
|
|
if (!step) return true;
|
|
const perm = step.permanentForm;
|
|
return !perm || perm.valid;
|
|
}
|
|
|
|
/** Human-readable explanation surfaced via matTooltip on the disabled button. */
|
|
get currentStepBlockerMessage(): string {
|
|
if (this.savingClient) return 'Saving — please wait…';
|
|
if (this.currentStepValid) return '';
|
|
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
|
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
|
if (disposition === 'FALSE_POSITIVE') {
|
|
return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.';
|
|
}
|
|
if (disposition === 'CONFIRMED_MATCH') {
|
|
return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.';
|
|
}
|
|
return 'Sanctions match found — review the match and record an outcome before proceeding.';
|
|
}
|
|
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
|
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
|
}
|
|
if (this.selectedTabIndex === 1) {
|
|
const labels = this.clientKycStep?.invalidFieldLabels || [];
|
|
if (labels.length) {
|
|
return `Please fill/correct: ${labels.join(', ')}.`;
|
|
}
|
|
}
|
|
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
|
if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) {
|
|
const step = this.clientFamilyMembersStep;
|
|
if (!step.hasMother) return 'Mother is required as a family member.';
|
|
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
|
}
|
|
return 'Please fill all mandatory fields on this tab to continue.';
|
|
}
|
|
|
|
/** Whether the current tab has unsaved edits */
|
|
isCurrentTabEdited(): boolean {
|
|
return this.tabEditMode[this.selectedTabIndex] === true;
|
|
}
|
|
|
|
/**
|
|
* Next button handler — saves changes via API if the current tab was edited, then moves to next tab.
|
|
*/
|
|
onNext() {
|
|
if (this.selectedTabIndex === 0) {
|
|
if (!this.clientGeneralForm?.valid) {
|
|
this.clientGeneralForm?.markAllAsTouched();
|
|
setTimeout(() => {
|
|
const firstInvalid = document.querySelector('.mat-mdc-form-field.ng-invalid');
|
|
if (firstInvalid) {
|
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 100);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If tab was edited, confirm and save before moving on
|
|
if (this.isCurrentTabEdited()) {
|
|
this.confirmAndSaveTab();
|
|
} else {
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show confirmation dialog and save the current tab's data via API.
|
|
* Shows a special warning if DOB was changed (re-KYC required).
|
|
*/
|
|
private confirmAndSaveTab() {
|
|
let heading = 'Confirm Edit';
|
|
let dialogContext = 'Are you sure you want to save the changes';
|
|
let dialogType = 'basic';
|
|
|
|
// Check if name or DOB changed on the General tab — warn about re-KYC
|
|
if (this.selectedTabIndex === 0) {
|
|
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
|
|
|
// Dual-OTP gate — defensive re-check. The "Save & Next" button is already disabled
|
|
// via `currentStepValid` while this is incomplete, but don't rely on that alone.
|
|
if (!this.contactVerificationComplete) {
|
|
this.snackBar.open('Verify the OTP sent to both the old and new mobile number/email before saving.', 'Close', {
|
|
duration: 5000
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (changes.nameChanged || changes.dobChanged) {
|
|
heading = 'Re-KYC Required';
|
|
dialogContext =
|
|
'Client name or date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
|
|
dialogType = 'warn';
|
|
}
|
|
}
|
|
|
|
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
|
data: { heading, dialogContext, type: dialogType }
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response?.confirm) {
|
|
this.saveCurrentTab();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save the current tab's data via API, then advance to the next tab.
|
|
*/
|
|
private saveCurrentTab() {
|
|
const tabIndex = this.selectedTabIndex;
|
|
|
|
// General Details tab (always index 0)
|
|
if (tabIndex === 0) {
|
|
const locale = this.settingsService.language.code;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const clientData = { ...this.client, dateFormat, locale };
|
|
delete clientData.officeId;
|
|
|
|
// Detect changes to sensitive fields before saving
|
|
const originalData = this.clientDataAndTemplate;
|
|
const changedFields = this.detectSensitiveFieldChanges(originalData);
|
|
|
|
// Dual-OTP verified ids — sent only for the field(s) that actually changed, per-field
|
|
// (not a single generic old/new pair) so the backend can tell mobile's OTPs apart from
|
|
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
|
// state fields for the full naming rationale.
|
|
if (changedFields.mobileChanged) {
|
|
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
|
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
|
} else {
|
|
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
|
}
|
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
|
}
|
|
if (changedFields.emailChanged) {
|
|
clientData.emailOldOtpId = this.oldEmailOtpId;
|
|
clientData.emailNewOtpId = this.newEmailOtpId;
|
|
}
|
|
|
|
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
|
|
// client record loaded when this edit page opened, rather than relying on comparing against
|
|
// the LIVE client later (which would show nothing changed once a checker approves the edit,
|
|
// since by then current == proposed).
|
|
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
|
|
|
|
this.savingClient = true;
|
|
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
|
|
// side effects that a direct update would have had immediately — SMS/Email contact-change
|
|
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
|
|
// different user actually approves the edit (approval replays this exact payload through
|
|
// the same updateClient() the direct PUT used to call, so the backend-driven notification
|
|
// still fires correctly, just at approval time instead of now).
|
|
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
|
|
next: () => {
|
|
this.savingClient = false;
|
|
this.tabEditMode[tabIndex] = false;
|
|
this.hasPendingGeneralEdit = true;
|
|
|
|
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
|
// the staged JSON edit, so these still apply immediately as before.
|
|
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
|
const seniorCitizenProofForm = new FormData();
|
|
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
|
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
|
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
|
this.clientsService
|
|
.uploadClientDocument(this.clientId, seniorCitizenProofForm)
|
|
.subscribe({ error: () => {} });
|
|
}
|
|
if (this.clientGeneralStep?.pwdCertificateFile) {
|
|
const pwdCertificateForm = new FormData();
|
|
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
|
|
pwdCertificateForm.append('name', 'pwdCertificate');
|
|
pwdCertificateForm.append('description', 'Disability Certificate');
|
|
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
|
|
}
|
|
|
|
this.snackBar.open(
|
|
'Edit submitted for approval — a different user must review it before it takes effect.',
|
|
'Close',
|
|
{
|
|
duration: 5000
|
|
}
|
|
);
|
|
this.resetContactVerificationState();
|
|
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
},
|
|
error: () => {
|
|
this.savingClient = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// KYC tab (always index 1)
|
|
if (tabIndex === 1) {
|
|
// Photograph + Signature are mandatory before leaving the KYC tab
|
|
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
|
this.clientKycStep.markPhotoSigTouched();
|
|
return;
|
|
}
|
|
this.savingClient = true;
|
|
|
|
// Upload photo if selected
|
|
if (this.clientKycStep?.photographFile) {
|
|
this.clientsService
|
|
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
|
|
.subscribe({ error: () => {} });
|
|
}
|
|
// Upload signature if selected
|
|
if (this.clientKycStep?.signatureFile) {
|
|
this.clientsService
|
|
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
|
|
.subscribe({ error: () => {} });
|
|
}
|
|
|
|
// Save KYC data — use correct API based on KYC state
|
|
if (this.clientKycStep?.hasKycData) {
|
|
const kycData = this.clientKycStep.kycData;
|
|
const kycId = this.clientKycStep.kycId;
|
|
const isVerified = this.clientKycStep.isKycVerified;
|
|
|
|
// Remarks are not demanded here. The server requires them only when a save actually
|
|
// overwrites data the verified record already held, and it names the reason when it
|
|
// refuses. Blocking every verified record up front aborted the save silently — the photo
|
|
// upload above had already gone through, so the image changed, the button came back to
|
|
// life, and nothing said why nothing had been saved.
|
|
let saveObs;
|
|
if (kycId && isVerified) {
|
|
// VERIFIED → re-edit command
|
|
const { kycType, ...updatePayload } = kycData;
|
|
saveObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
|
|
} else if (kycId) {
|
|
// Existing but not verified → normal PUT
|
|
const { kycType, ...updatePayload } = kycData;
|
|
saveObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
|
|
} else {
|
|
// New → POST
|
|
saveObs = this.clientsService.saveClientKyc(this.clientId, kycData);
|
|
}
|
|
|
|
saveObs.subscribe({
|
|
next: (res: any) => {
|
|
this.savingClient = false;
|
|
this.tabEditMode[tabIndex] = false;
|
|
// If re-edit, update KYC component state
|
|
if (isVerified && this.clientKycStep) {
|
|
this.clientKycStep.kycStatus = 200;
|
|
this.clientKycStep.panVerifyStatus = 'NONE';
|
|
this.clientKycStep.panVerifyName = '';
|
|
this.clientKycStep.panVerifiedOn = '';
|
|
}
|
|
if (!kycId && res?.resourceId) {
|
|
this.clientKycStep.kycId = res.resourceId.toString();
|
|
}
|
|
// Persist KYC document picker selection + uploaded files as a client identifier
|
|
this.saveKycDocProof(this.clientId);
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
},
|
|
// A rejected save used to just re-enable the button, which reads as "nothing happened".
|
|
// The server's own message says what is missing — remarks on a re-edit, most often — so
|
|
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
|
|
error: (err: any) => {
|
|
this.savingClient = false;
|
|
const message =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
'KYC could not be saved. Please review the KYC details and try again.';
|
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
|
if (isVerified) {
|
|
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
this.savingClient = false;
|
|
this.tabEditMode[tabIndex] = false;
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Address tab — no OTP gate (an address has no "old/new channel" to verify an OTP
|
|
// against); notification-only, per the plan. Save proceeds normally.
|
|
const addressTabIdx = this.legalFormType === 2 ? 3 : 2;
|
|
if (tabIndex === addressTabIdx) {
|
|
this.savingClient = true;
|
|
if (this.clientIndiaAddressStep?.hasAddressData) {
|
|
this.saveClientAddresses(this.clientId);
|
|
this.snackBar.open('Customer notified via SMS/Email of the address change.', 'Close', { duration: 4000 });
|
|
}
|
|
this.savingClient = false;
|
|
this.tabEditMode[tabIndex] = false;
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
return;
|
|
}
|
|
|
|
// Family tab — only present for individuals and proprietorship entities.
|
|
// Skip when hidden so Preview at the same index isn't mistaken for Family.
|
|
const familyTabIdx = this.legalFormType === 2 ? 4 : 3;
|
|
if (this.showFamilyTab && tabIndex === familyTabIdx) {
|
|
this.savingClient = true;
|
|
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
|
this.saveClientFamilyMembers(this.clientId);
|
|
}
|
|
this.savingClient = false;
|
|
this.tabEditMode[tabIndex] = false;
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
return;
|
|
}
|
|
|
|
// Default: just advance
|
|
this.tabEditMode[tabIndex] = false;
|
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
|
}
|
|
|
|
/**
|
|
* Compare current form values with original data to detect changes in name, DOB, mobile, email.
|
|
*/
|
|
/** "YYYY-MM-DD" built from a Date's LOCAL components — see the timezone-safety note where
|
|
* this is called from `detectSensitiveFieldChanges()`. */
|
|
private toLocalDateString(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
private detectSensitiveFieldChanges(originalData: any) {
|
|
const form = this.clientGeneralForm;
|
|
if (!form || !originalData) {
|
|
return {
|
|
nameChanged: false,
|
|
mobileChanged: false,
|
|
emailChanged: false,
|
|
dobChanged: false,
|
|
oldName: '',
|
|
newName: '',
|
|
oldMobile: '',
|
|
newMobile: '',
|
|
oldEmail: '',
|
|
newEmail: '',
|
|
oldDob: '',
|
|
newDob: ''
|
|
};
|
|
}
|
|
|
|
const mobileControl = form.get('mobileNo');
|
|
const emailControl = form.get('emailAddress');
|
|
|
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
|
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
|
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
|
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
|
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
|
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
|
// customer's saved value or the OTP destination.
|
|
const newMobile = (mobileControl?.value || '').toString().trim();
|
|
const newEmail = (emailControl?.value || '').toString().trim();
|
|
const newDobRaw = form.get('dateOfBirth')?.value;
|
|
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
|
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
|
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
|
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
|
// touched the date field, purely because the Date object round-tripped through the picker.
|
|
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
|
|
|
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
|
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
|
const oldDobRaw = originalData.dateOfBirth;
|
|
const oldDob = Array.isArray(oldDobRaw)
|
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
|
: oldDobRaw instanceof Date
|
|
? this.toLocalDateString(oldDobRaw)
|
|
: oldDobRaw || '';
|
|
|
|
// Both sides run through toProperNameCase so a casing-only difference — the stored
|
|
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
|
|
// mistaken for a real rename and does not force a PAN re-verification.
|
|
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
|
toProperNameCase(
|
|
parts.fullname ||
|
|
[
|
|
parts.firstname,
|
|
parts.middlename,
|
|
parts.lastname
|
|
]
|
|
.filter((p) => p && p.trim())
|
|
.join(' ')
|
|
);
|
|
|
|
const newName = buildName({
|
|
firstname: form?.get('firstname')?.value || '',
|
|
middlename: form?.get('middlename')?.value || '',
|
|
lastname: form?.get('lastname')?.value || '',
|
|
fullname: form?.get('fullname')?.value || ''
|
|
});
|
|
const oldName = buildName({
|
|
firstname: originalData.firstname || '',
|
|
middlename: originalData.middlename || '',
|
|
lastname: originalData.lastname || '',
|
|
fullname: originalData.fullname || ''
|
|
});
|
|
|
|
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
|
const normPhone = (val: string) => val.replace(/\D/g, '');
|
|
const newMobileNorm = normPhone(newMobile);
|
|
const oldMobileNorm = normPhone(oldMobile);
|
|
|
|
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
|
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
|
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
|
const mobileChanged =
|
|
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
|
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
|
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
|
const emailChanged =
|
|
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
|
|
|
return {
|
|
nameChanged: newName !== oldName,
|
|
mobileChanged,
|
|
emailChanged,
|
|
dobChanged: newDob !== oldDob,
|
|
oldName,
|
|
newName,
|
|
oldMobile,
|
|
newMobile,
|
|
oldEmail,
|
|
newEmail,
|
|
oldDob,
|
|
newDob
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save audit notes for mobile/email changes via the client notes API.
|
|
*/
|
|
private logFieldChangeNotes(changes: any) {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
if (changes.mobileChanged) {
|
|
const note = `[AUDIT] Mobile number changed from "${changes.oldMobile}" to "${changes.newMobile}" on ${timestamp}`;
|
|
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
|
|
}
|
|
|
|
if (changes.emailChanged) {
|
|
const note = `[AUDIT] Email address changed from "${changes.oldEmail}" to "${changes.newEmail}" on ${timestamp}`;
|
|
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When name or DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
|
|
*/
|
|
private flagReKycRequired(changes: {
|
|
nameChanged: boolean;
|
|
dobChanged: boolean;
|
|
oldName: string;
|
|
newName: string;
|
|
oldDob: string;
|
|
newDob: string;
|
|
}) {
|
|
const reasons: string[] = [];
|
|
if (changes.nameChanged) {
|
|
reasons.push(`Client name changed from "${changes.oldName}" to "${changes.newName}"`);
|
|
}
|
|
if (changes.dobChanged) {
|
|
reasons.push(`Date of birth changed from "${changes.oldDob}" to "${changes.newDob}"`);
|
|
}
|
|
const reasonText = reasons.join('; ');
|
|
|
|
// Reset PAN verification status so user must re-verify
|
|
if (this.clientKycStep) {
|
|
this.clientKycStep.panVerifyStatus = 'NONE';
|
|
this.clientKycStep.panVerifyName = '';
|
|
this.clientKycStep.panVerifiedOn = '';
|
|
// Pre-fill remarks with re-KYC note
|
|
const remarkText = `[Re-KYC Required] ${reasonText}. PAN re-verification is mandatory.`;
|
|
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
|
|
}
|
|
|
|
// Also log the change as an audit note
|
|
const timestamp = new Date().toISOString();
|
|
const note = `[AUDIT] ${reasonText} on ${timestamp}. Re-KYC (PAN re-verification) required.`;
|
|
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
|
|
}
|
|
|
|
legalFormChange(eventData: { legalForm: number }) {
|
|
this.legalFormType = eventData.legalForm;
|
|
}
|
|
|
|
/**
|
|
* Submits the edit client form.
|
|
*/
|
|
submit() {
|
|
// Photograph + Signature are mandatory before submitting
|
|
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
|
this.clientKycStep.markPhotoSigTouched();
|
|
this.selectedTabIndex = 1;
|
|
return;
|
|
}
|
|
|
|
const locale = this.settingsService.language.code;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const clientData = {
|
|
...this.client,
|
|
dateFormat,
|
|
locale
|
|
};
|
|
|
|
// Remove fields not accepted by update API
|
|
delete clientData.officeId;
|
|
|
|
this.savingClient = true;
|
|
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
|
next: () => {
|
|
this.savingClient = false;
|
|
const navigateBack = () => this.router.navigate(['../'], { relativeTo: this.route });
|
|
|
|
// Upload photo if selected
|
|
if (this.clientKycStep?.photographFile) {
|
|
this.clientsService
|
|
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
|
|
.subscribe({ error: () => {} });
|
|
}
|
|
|
|
// Upload signature if selected
|
|
if (this.clientKycStep?.signatureFile) {
|
|
this.clientsService
|
|
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
|
|
.subscribe({ error: () => {} });
|
|
}
|
|
|
|
// Save addresses from India address step
|
|
if (this.clientIndiaAddressStep?.hasAddressData) {
|
|
this.saveClientAddresses(this.clientId);
|
|
}
|
|
|
|
// Save new family members
|
|
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
|
|
this.saveClientFamilyMembers(this.clientId);
|
|
}
|
|
|
|
// Save/update KYC data if available
|
|
if (this.clientKycStep?.hasKycData) {
|
|
const kycData = this.clientKycStep.kycData;
|
|
const kycId = this.clientKycStep.kycId;
|
|
const isVerified = this.clientKycStep.isKycVerified;
|
|
let kycObs;
|
|
if (kycId && isVerified) {
|
|
const { kycType, ...updatePayload } = kycData;
|
|
kycObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
|
|
} else if (kycId) {
|
|
const { kycType, ...updatePayload } = kycData;
|
|
kycObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
|
|
} else {
|
|
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
|
|
}
|
|
kycObs.subscribe({
|
|
next: () => {
|
|
this.saveKycDocProof(this.clientId);
|
|
navigateBack();
|
|
},
|
|
error: () => navigateBack()
|
|
});
|
|
} else {
|
|
this.saveKycDocProof(this.clientId);
|
|
navigateBack();
|
|
}
|
|
},
|
|
error: () => {
|
|
this.savingClient = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save addresses from the India address step to Fineract address API.
|
|
*/
|
|
private saveClientAddresses(clientId: string) {
|
|
const addressData = this.clientIndiaAddressStep.addressData;
|
|
const typeNameMap: Record<string, string[]> = {
|
|
permanent: [
|
|
'Permanent',
|
|
'Permanent Address'
|
|
],
|
|
current: [
|
|
'Residential',
|
|
'Current',
|
|
'Current / Residential',
|
|
'Communication'
|
|
],
|
|
office: [
|
|
'Office',
|
|
'Office / Work',
|
|
'Business',
|
|
'Work'
|
|
],
|
|
correspondence: [
|
|
'Correspondence',
|
|
'Mailing',
|
|
'Postal'
|
|
]
|
|
};
|
|
|
|
this.clientsService.getClientAddressTemplate().subscribe({
|
|
next: (template: any) => {
|
|
const typeOptions = template?.addressTypeIdOptions || [];
|
|
for (const [
|
|
tab,
|
|
addr
|
|
] of Object.entries(addressData)) {
|
|
if (!addr || !(addr as any).addressLine1) continue;
|
|
|
|
const possibleNames = typeNameMap[tab] || [tab];
|
|
let typeId = typeOptions.find((t: any) =>
|
|
possibleNames.some((n: string) => t.name?.toLowerCase().includes(n.toLowerCase()))
|
|
)?.id;
|
|
|
|
if (!typeId && typeOptions.length > 0) typeId = typeOptions[0].id;
|
|
if (!typeId) continue;
|
|
|
|
const payload: any = {
|
|
addressLine1: (addr as any).addressLine1 || '',
|
|
addressLine2: (addr as any).addressLine2 || '',
|
|
addressLine3: '',
|
|
townVillage: (addr as any).locality || '',
|
|
postalCode: (addr as any).pinCode || '',
|
|
city: (addr as any).city || '',
|
|
countyDistrict: (addr as any).district || '',
|
|
street: (addr as any).landmark || '',
|
|
addressType: typeId,
|
|
isActive: tab === 'permanent'
|
|
};
|
|
|
|
// Resolve state name to stateProvinceId. Normalise both sides (lowercase,
|
|
// collapse underscores + whitespace) so legacy UPPER_SNAKE rows still match
|
|
// the postal-PIN API's "Madhya Pradesh" form and vice versa. See migration 0297.
|
|
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
|
|
const norm = (s: string) =>
|
|
(s || '')
|
|
.toLowerCase()
|
|
.replace(/[_\s]+/g, ' ')
|
|
.trim();
|
|
const target = norm((addr as any).state);
|
|
const stateMatch = template.stateProvinceIdOptions.find((s: any) => norm(s.name) === target);
|
|
if (stateMatch) payload.stateProvinceId = stateMatch.id;
|
|
}
|
|
|
|
this.clientsService.createClientAddress(clientId, typeId.toString(), payload).subscribe({ error: () => {} });
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
|
|
// Persist the selected proof of address as a client identifier (with attached files)
|
|
this.saveAddressProof(clientId);
|
|
}
|
|
|
|
/**
|
|
* Save the address-proof picked on the permanent tab as a client identifier
|
|
* + upload each selected file as a document attached to that identifier.
|
|
* Mirrors identities-tab.createIdentifierWithDocs.
|
|
*/
|
|
private saveAddressProof(clientId: string) {
|
|
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
|
return;
|
|
}
|
|
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
|
if (!payload?.proofId) return;
|
|
|
|
const proofName = payload.proof?.name || 'Address Proof';
|
|
const identifierData: any = {
|
|
documentTypeId: payload.proofId,
|
|
status: 'Active',
|
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
|
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
|
|
};
|
|
|
|
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
|
next: (res: any) => {
|
|
const identifierId = res?.resourceId;
|
|
if (!identifierId) return;
|
|
if (this.clientIndiaAddressStep) {
|
|
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
|
}
|
|
for (const entry of payload.files) {
|
|
const formData = new FormData();
|
|
formData.append('name', `${proofName} — ${entry.slot}`);
|
|
formData.append('file', entry.file);
|
|
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save the KYC document picked on the KYC tab as a client identifier
|
|
* + upload each selected file as a document attached to that identifier.
|
|
*/
|
|
private saveKycDocProof(clientId: string) {
|
|
const payload = this.clientKycStep?.kycDocPayload;
|
|
if (!payload?.proofId || !payload?.files?.length) return;
|
|
|
|
const proofName = payload.proof?.name || 'KYC Document';
|
|
|
|
const uploadFiles = (identifierId: string) => {
|
|
for (const entry of payload.files) {
|
|
const formData = new FormData();
|
|
formData.append('name', `${proofName} — ${entry.slot}`);
|
|
formData.append('file', entry.file);
|
|
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
|
}
|
|
};
|
|
|
|
if (this.clientKycStep?.existingKycIdentifierId) {
|
|
uploadFiles(this.clientKycStep.existingKycIdentifierId);
|
|
return;
|
|
}
|
|
|
|
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
|
|
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
|
|
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
|
|
// id by matching on name against /kyc/proof-types?type=id before posting.
|
|
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
|
if (documentTypeId == null) {
|
|
return;
|
|
}
|
|
const identifierData: any = {
|
|
documentTypeId,
|
|
status: 'Active',
|
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
|
description: 'KYC document'
|
|
};
|
|
|
|
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
|
next: (res: any) => {
|
|
const identifierId = res?.resourceId;
|
|
if (!identifierId) return;
|
|
if (this.clientKycStep) {
|
|
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
|
}
|
|
uploadFiles(identifierId.toString());
|
|
},
|
|
error: () => {}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pass-through when proofId is already numeric (address proof). Otherwise resolve the
|
|
* string code/name against the ID-proof CodeValues so the backend gets a proper Long.
|
|
*/
|
|
private resolveDocumentTypeId(proofId: any, proofName: string): Observable<number | null> {
|
|
if (typeof proofId === 'number' && Number.isFinite(proofId)) {
|
|
return of(proofId);
|
|
}
|
|
return this.clientsService.getKycProofTypes('id').pipe(
|
|
map((res: any) => {
|
|
const list: any[] = res?.idProofs || [];
|
|
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
const target = cleanStr(proofName || proofId);
|
|
|
|
// 1. Try exact code/name match
|
|
let match = list.find((p: any) => {
|
|
const pcode = cleanStr(p.code);
|
|
const pname = cleanStr(p.name);
|
|
return pcode === target || pname === target;
|
|
});
|
|
|
|
// 2. Try loose contains/includes match
|
|
if (!match) {
|
|
match = list.find((p: any) => {
|
|
const pcode = cleanStr(p.code);
|
|
const pname = cleanStr(p.name);
|
|
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
|
});
|
|
}
|
|
|
|
// 3. Specific fallbacks for common keywords
|
|
if (!match && target.includes('voter')) {
|
|
match = list.find((p: any) => {
|
|
const name = cleanStr(p.name);
|
|
return name.includes('voter') || name.includes('epic');
|
|
});
|
|
}
|
|
if (!match && target.includes('aadhaar')) {
|
|
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
|
}
|
|
if (!match && target.includes('passport')) {
|
|
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
|
}
|
|
if (!match && target.includes('driving')) {
|
|
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
|
}
|
|
if (!match && target.includes('pan')) {
|
|
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
|
}
|
|
|
|
return (match?.id ?? null) as number | null;
|
|
})
|
|
);
|
|
}
|
|
|
|
private saveClientFamilyMembers(clientId: string) {
|
|
const members = this.clientFamilyMembersStep.clientFamilyMembers;
|
|
for (const member of members) {
|
|
// Skip existing members (already have an id from the API)
|
|
if (member.id) continue;
|
|
const marriageCertFile = member._marriageCertFile || null;
|
|
const payload = { ...member };
|
|
// Remove internal/transient fields
|
|
delete payload._linkedClient;
|
|
delete payload._marriageCertFile;
|
|
delete payload.dateFormat;
|
|
delete payload.locale;
|
|
payload.dateFormat = this.settingsService.dateFormat;
|
|
payload.locale = this.settingsService.language.code;
|
|
this.clientsService.addFamilyMember(clientId, payload).subscribe({
|
|
next: (res: any) => {
|
|
// Upload marriage certificate if present
|
|
if (marriageCertFile && res?.resourceId) {
|
|
this.uploadFamilyMemberDocument(clientId, res.resourceId, marriageCertFile, 'Marriage Certificate');
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload a document for a family member.
|
|
*/
|
|
private uploadFamilyMemberDocument(clientId: string, familyMemberId: string, file: File, docName: string) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('name', docName);
|
|
formData.append('description', docName);
|
|
this.clientsService.uploadFamilyMemberDocument(clientId, familyMemberId, formData).subscribe({ error: () => {} });
|
|
}
|
|
}
|