/** * 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 { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; /** Custom Services */ import { ClientsService } from '../clients.service'; import { familyAllowed, nomineeAllowed, isProprietorship } 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'; /** Custom Services */ import { SettingsService } from 'app/settings/settings.service'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule, MatIcon } from '@angular/material/icon'; import { MatDivider } from '@angular/material/divider'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatDialog, MAT_DIALOG_DATA, MatDialogClose } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { Inject } from '@angular/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** * Create Client Component. */ @Component({ selector: 'mifosx-create-client', templateUrl: './create-client.component.html', styleUrls: ['./create-client.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatTabsModule, MatIconModule, MatDivider, FaIconComponent, MatProgressSpinner, ClientGeneralStepComponent, ClientAddressStepComponent, ClientIndiaAddressStepComponent, ClientEntityKycStepComponent, ClientDatatableStepComponent, ClientKycStepComponent, ClientPreviewStepComponent, ClientFamilyMembersStepComponent, MatProgressBarModule, MatTooltip ] }) export class CreateClientComponent { private route = inject(ActivatedRoute); private router = inject(Router); private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private dialog = inject(MatDialog); 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; datatables: any = []; legalFormType = 1; selectedTabIndex = 0; createdClientId = ''; savingClient = false; /** Pre-fill data passed from "Create as Client" on a manual family member */ prefillData: any = null; /** Track which tabs are in edit mode (false/undefined = read-only for completed tabs) */ tabEditMode: Record = {}; /** Highest tab index reached — tabs below this show read-only when navigated back */ highestTabReached = 0; /** Client Template */ clientTemplate: any; /** Client Address Field Config */ clientAddressFieldConfig: any; /** * Fetches client and address template from `resolve` * @param {ActivatedRoute} route Activated Route * @param {Router} router Router * @param {ClientsService} clientsService Clients Service * @param {SettingsService} settingsService Setting service */ constructor() { this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => { this.clientTemplate = data.clientTemplate; this.clientAddressFieldConfig = data.clientAddressFieldConfig; // This app's RouteReusableStrategy keeps this SAME component instance alive across // repeat visits to /clients/create, so ngOnInit/constructor won't re-run for a second // "create new client" session — reset transient state here instead, since `route.data` // re-emits on every navigation regardless of whether the component was reused. Must run // BEFORE setDatatables(), which reads legalFormType to filter clientTemplate.datatables. this.resetForNewClient(); this.setDatatables(); }); // Read pre-fill data set by "Create as Client" from a manual family member const nav = this.router.getCurrentNavigation(); const navState = nav?.extras?.state ?? (typeof history !== 'undefined' ? history.state : null); if (navState?.prefill) { this.prefillData = navState.prefill; } } /** Resets session state that could otherwise leak between clients when the route is reused. */ private resetForNewClient(): void { this.legalFormType = 1; this.selectedTabIndex = 0; this.createdClientId = ''; this.savingClient = false; this.saveProgress = 0; this.tabEditMode = {}; this.highestTabReached = 0; this.validationError = ''; this.prefillData = null; this.clientGeneralStep?.resetForNewClient(); this.clientKycStep?.resetForNewClient(); this.clientIndiaAddressStep?.resetForNewClient(); } /** Check if a tab is in read-only mode (completed tab navigated back to) */ isTabReadOnly(tabIndex: number): boolean { // Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode return tabIndex < this.highestTabReached && !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 for the selected entity constitution. Drives the * proprietorship-only nominee rule below. Empty / null for individual clients. */ get constitutionLabel(): string { return this.clientGeneralStep?.selectedConstitution?.name || ''; } /** Whether the Family/Nominee tab should appear at all. */ get showFamilyTab(): boolean { return nomineeAllowed(this.legalFormType, this.constitutionLabel); } /** * Whether the Family/Nominee tab should render in nominee-only mode (family * member rows hidden). True for entity proprietorship, false for individuals. */ 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; } /** * Per-tab form-validity gate for the Save & Next button. * Returns false when the current tab has unfilled / invalid required fields * so the user can't move forward (and the button is visibly disabled). * The mapping follows the tab order in create-client.component.html: * 0 General * 1 KYC & Documents * 2 (non-individual) Scheme & Sector | (individual) Address * 3 (non-individual) Address | (individual) Family * 4 Application Summary (handled by Submit, not Save & Next) */ 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; // A number the officer rejected on the DIP result must be replaced, not carried forward. if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false; if (!this.clientGeneralForm.valid) return false; // Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new // client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent. return this.clientGeneralStep.contactVerified; } if (idx === 1) { // KYC tab — block when the kyc form has unfilled required fields // (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.) const kf = this.clientKycStep?.kycForm; if (kf && !kf.valid) { const invalid = []; for (const name in kf.controls) { if (kf.controls[name].invalid) { invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value }); } } console.log('--- KYC Form is INVALID. Offending controls:', invalid); } return !kf || kf.valid; } if (this.legalFormType === 2) { if (idx === 2) { // Scheme & Sector (entity KYC) — categories are opt-in, so always allow. return true; } if (idx === 3) { // Address tab for non-individuals return this.isAddressStepValid(); } } else { if (idx === 2) { // Address tab for individuals return this.isAddressStepValid(); } if (idx === 3) { // Family tab — Mother is required, and either Father or a Caretaker. return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet; } } return true; } /** Permanent address is the only mandatory address; current/office/correspondence are optional. */ private isAddressStepValid(): boolean { const step = this.clientIndiaAddressStep; if (!step) return true; // step not yet mounted — don't block const perm = step.permanentForm; return !perm || perm.valid; } /** * Human-readable explanation of why the Save & Next button is disabled. * Surfaced via matTooltip so the user knows what to fix. */ get currentStepBlockerMessage(): string { if (this.savingClient) return 'Saving — please wait…'; if (this.currentStepValid) return ''; const idx = this.selectedTabIndex; if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) { const disposition = this.clientGeneralStep.sanctionsDisposition; if (disposition === 'FALSE_POSITIVE') { return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.'; } if (disposition === 'CONFIRMED_MATCH') { return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.'; } return 'Sanctions match found — review the match and record an outcome before proceeding.'; } if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) { return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.'; } if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) { return 'Verify the OTP sent to both the mobile number and email address before proceeding.'; } if (idx === 1) { const labels = this.clientKycStep?.invalidFieldLabels || []; if (labels.length) { return `Please fill/correct: ${labels.join(', ')}.`; } } if (idx === 3 && this.legalFormType === 1 && 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.'; } /** 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 || {}; const addr = this.clientTemplate.isAddressEnabled && this.clientAddressStep ? this.clientAddressStep.address : {}; return { ...general, ...addr }; } areFormvalids(): boolean { if (!this.clientGeneralStep || !this.clientGeneralForm) { return false; } let areValids = this.clientGeneralForm.valid; if (this.clientGeneralStep.sanctionsBlocked) { return false; } if (this.clientTemplate.isAddressEnabled && this.clientAddressStep) { areValids = areValids && this.clientAddressStep.address.address.length > 0; } if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0 && this.clientDatatables) { this.clientDatatables.forEach((clientDatatable: ClientDatatableStepComponent) => { areValids = areValids && clientDatatable.datatableForm.valid; }); } return areValids; } setDatatables(): void { this.datatables = []; let legalFormTypeVal = 'person'; if (this.legalFormType === 2) { legalFormTypeVal = 'entity'; } if (this.clientTemplate.datatables) { this.clientTemplate.datatables.forEach((datatable: any) => { if (datatable.entitySubType.toLowerCase() === legalFormTypeVal) { this.datatables.push(datatable); } }); } } /** * Next button handler — saves client on General tab before moving forward. */ /** Save progress percentage for progress bar */ saveProgress = 0; onNext() { // If on General tab and client not yet created — save first if (this.selectedTabIndex === 0 && !this.createdClientId) { 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; } // Contact-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 (mirrors the // same defensive re-check in EditClientComponent.confirmAndSaveTab()). if (!this.clientGeneralStep?.contactVerified) { this.snackBar.open('Verify the OTP sent to both the mobile number and email address before saving.', 'Close', { duration: 5000 }); return; } // PWD gate — disability type + certificate are mandatory when PWD is checked // (Senior Citizen proof upload is optional, not gated here) if (!this.clientGeneralStep?.seniorPwdDocsComplete) { this.clientGeneralStep?.markSeniorPwdDocsTouched(); this.snackBar.open( 'Select the disability type and upload the Disability Certificate before continuing.', 'Close', { duration: 5000 } ); return; } // Sanctions verification gate — Verify Client must be clicked before save if (!this.clientGeneralStep?.sanctionsVerified) { this.dialog .open(VerifyClientAlertDialogComponent, { width: '420px', autoFocus: false, data: { isEntity: this.legalFormType === 2 } }) .afterClosed() .subscribe(() => { setTimeout(() => { const verifyBtn = document.querySelector('.verify-name-btn') as HTMLElement; if (verifyBtn) { verifyBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); verifyBtn.focus(); } }, 100); }); return; } // Show confirmation dialog const dialogRef = this.dialog.open(SaveConfirmDialogComponent, { width: '440px', disableClose: true }); dialogRef.afterClosed().subscribe((confirmed: boolean) => { if (!confirmed) return; this.saveAndProceed(); }); return; } // Save current tab data before moving to next if (this.createdClientId) { this.saveCurrentTabData(); } this.selectedTabIndex = this.selectedTabIndex + 1; if (this.selectedTabIndex > this.highestTabReached) { this.highestTabReached = this.selectedTabIndex; } } /** Save the current tab's data to the API */ private saveCurrentTabData() { const tabIndex = this.selectedTabIndex; const clientId = this.createdClientId; // KYC tab const kycTabIndex = 1; if (tabIndex === kycTabIndex) { // Photograph + Signature are mandatory before leaving the KYC tab if (this.clientKycStep && !this.clientKycStep.photoSigComplete) { this.clientKycStep.markPhotoSigTouched(); this.validationError = 'Photograph and Signature are required before continuing.'; return; } this.validationError = ''; // Save KYC if (this.clientKycStep?.hasKycData) { if (!this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ next: (res: any) => { if (this.clientKycStep) { this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || ''; } }, error: () => {} }); } else { const { kycType, ...updatePayload } = this.clientKycStep.kycData; this.clientsService .updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload) .subscribe({ error: () => {} }); } } // Upload photo & signature if (this.clientKycStep?.photographFile) { this.clientsService .uploadClientProfileImage(clientId, this.clientKycStep.photographFile) .subscribe({ error: () => {} }); } if (this.clientKycStep?.signatureFile) { this.clientsService .uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile) .subscribe({ error: () => {} }); } // Persist the KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(clientId); return; } // Address tab (index 2 for Individual, 3 for Non-Individual) const addressTabIndex = this.legalFormType === 2 ? 3 : 2; if (tabIndex === addressTabIndex) { if (this.clientIndiaAddressStep?.hasAddressData) { this.saveClientAddresses(clientId); } return; } // Family tab (index 3 for Individual, 4 for Non-Individual proprietorship). // Skip entirely when the tab is hidden (entity non-proprietor) so we don't // confuse Preview at the same index slot with the Family step. const familyTabIndex = this.legalFormType === 2 ? 4 : 3; if (this.showFamilyTab && tabIndex === familyTabIndex) { if (this.clientFamilyMembersStep?.hasFamilyMembers) { this.saveClientFamilyMembers(clientId); } return; } } /** Executes the save API call with progress animation */ private saveAndProceed() { this.savingClient = true; this.saveProgress = 0; // Animate progress bar const progressInterval = setInterval(() => { if (this.saveProgress < 90) { this.saveProgress += Math.random() * 15; if (this.saveProgress > 90) this.saveProgress = 90; } }, 200); const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, locale }; // Mandatory contact-OTP references — required by POST /clients whenever mobileNo/ // emailAddress are present (they always are, both are Validators.required on this form). // Gated by `contactVerified` above, so these are guaranteed non-null at this point. clientData.mobileNewOtpId = this.clientGeneralStep?.mobileNewOtpId; clientData.emailNewOtpId = this.clientGeneralStep?.emailNewOtpId; if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) { const datatables: any[] = []; this.clientDatatables?.forEach((clientDatatable: ClientDatatableStepComponent) => { datatables.push(clientDatatable.payload); }); if (datatables.length > 0) { clientData['datatables'] = datatables; } } this.clientsService.createClient(clientData).subscribe({ next: (response: any) => { clearInterval(progressInterval); this.saveProgress = 100; this.createdClientId = response.resourceId; // Senior Citizen / PWD supporting documents — uploaded after creation, same pattern as // the KYC step's photo/signature uploads (the generic /clients/{id}/documents endpoint // needs a real clientId, which only exists once createClient() has returned). 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.createdClientId, 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.createdClientId, pwdCertificateForm) .subscribe({ error: () => {} }); } // Brief delay to show 100% before transitioning setTimeout(() => { this.savingClient = false; this.saveProgress = 0; this.selectedTabIndex = 1; this.highestTabReached = 1; }, 400); }, error: () => { clearInterval(progressInterval); this.savingClient = false; this.saveProgress = 0; } }); } legalFormChange(eventData: { legalForm: number }) { this.legalFormType = eventData.legalForm; this.setDatatables(); } /** * Submits the create client form. * If the client was already created via onNext(), reuse that client ID * instead of creating a duplicate. */ /** Validation error message for display */ validationError = ''; submit() { // Validate: photograph and signature are mandatory if (this.clientKycStep && !this.clientKycStep.photoSigComplete) { this.clientKycStep.markPhotoSigTouched(); this.validationError = 'Photograph and Signature are required before submitting.'; // Switch to KYC tab this.selectedTabIndex = 1; return; } // Validate: at least one family member required (Individual clients) if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) { this.validationError = 'At least one family member is required. Please add a family member before submitting.'; // Switch to Family tab this.selectedTabIndex = 3; return; } // Validate: Mother required, and either Father or a Caretaker (Individual clients) if ( this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.familyRelationshipRulesMet ) { const step = this.clientFamilyMembersStep; if (!step.hasMother) { this.validationError = 'Mother is required as a family member.'; } else { this.validationError = 'Either Father or a Caretaker is required as a family member.'; } this.selectedTabIndex = 3; return; } this.validationError = ''; if (this.createdClientId) { // Client already created in onNext() — just finalize (upload files, navigate) this.finalizeClient(this.createdClientId); } else { // Fallback: create client now (shouldn't happen in normal flow) if (!this.clientGeneralStep?.contactVerified) { this.validationError = 'Verify the OTP sent to both the mobile number and email address before submitting.'; this.selectedTabIndex = 0; return; } const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, locale, mobileNewOtpId: this.clientGeneralStep?.mobileNewOtpId, emailNewOtpId: this.clientGeneralStep?.emailNewOtpId }; if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) { const datatables: any[] = []; this.clientDatatables.forEach((clientDatatable: ClientDatatableStepComponent) => { datatables.push(clientDatatable.payload); }); if (datatables.length > 0) { clientData['datatables'] = datatables; } } this.clientsService.createClient(clientData).subscribe((response: any) => { this.createdClientId = response.resourceId; this.finalizeClient(this.createdClientId); }); } } /** * Finalize: upload photo/signature, save KYC, save addresses, then navigate. */ private finalizeClient(clientId: string) { const doNavigate = () => this.router.navigate( [ '../', clientId ], { relativeTo: this.route } ); const navigateToClient = () => { const fm = this.prefillData; if (fm?.familyMemberId && fm?.parentClientId) { this.clientsService .editFamilyMember(fm.parentClientId.toString(), fm.familyMemberId.toString(), { linkedClientId: Number(clientId) }) .subscribe({ next: () => { this.snackBar.open('Family member linked to this client successfully.', 'Close', { duration: 4000 }); doNavigate(); }, error: () => { this.snackBar.open( 'Client created, but family member could not be auto-linked. Please link manually.', 'Close', { duration: 6000 } ); doNavigate(); } }); } else { doNavigate(); } }; // Upload photo if selected if (this.clientKycStep?.photographFile) { this.clientsService .uploadClientProfileImage(clientId, this.clientKycStep.photographFile) .subscribe({ error: () => {} }); } // Upload signature if selected if (this.clientKycStep?.signatureFile) { this.clientsService .uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile) .subscribe({ error: () => {} }); } // Save addresses from India address step if (this.clientIndiaAddressStep?.hasAddressData) { this.saveClientAddresses(clientId); } // Save family members if (this.clientFamilyMembersStep?.hasFamilyMembers) { this.saveClientFamilyMembers(clientId); } // Persist the KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(clientId); // Save/update KYC to ensure latest exception fields are persisted if (this.clientKycStep?.hasKycData) { if (!this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ next: () => navigateToClient(), error: () => navigateToClient() }); } else { const { kycType, ...updatePayload } = this.clientKycStep.kycData; this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ next: () => navigateToClient(), error: () => navigateToClient() }); } } else { navigateToClient(); } } /** * Save addresses from the India address step to Fineract address API. * Fetches address template to resolve address type IDs, then POSTs each address. */ private saveClientAddresses(clientId: string) { const addressData = this.clientIndiaAddressStep.addressData; // Map tab names to Fineract address type names (as defined in address template) const typeNameMap: Record = { 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; // Find matching address type ID const possibleNames = typeNameMap[tab] || [tab]; let typeId = typeOptions.find((t: any) => possibleNames.some((n: string) => t.name?.toLowerCase().includes(n.toLowerCase())) )?.id; // Fallback: use first available type if no match 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' }; // Try to resolve state to stateProvinceId. We 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) { if (this.clientKycStep?.existingKycIdentifierId) { return; } const payload = this.clientKycStep?.kycDocPayload; if (!payload?.proofId || !payload?.files?.length) return; const proofName = payload.proof?.name || 'KYC Document'; // The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR") // because its list is constitution-driven, not CodeValue-backed. The backend // ClientIdentifierRequest.documentTypeId is a Long, so a string proofId trips Jackson // with HttpMessageNotReadableException → 400. Resolve to the 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) { // No CodeValue match (e.g. non-individual constitution-specific docs). Skip the // identifier save rather than firing a 400; the form's other KYC fields still get // persisted via the KYC entity. 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(); } 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: () => {} }); }); } /** * Pass-through when proofId is already numeric (address proof, identities tab). Otherwise * resolve the string code/name against the ID-proof CodeValues so the backend gets a * proper Long for documentTypeId. */ private resolveDocumentTypeId(proofId: any, proofName: string): Observable { 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; }) ); } /** * Save family members via POST /clients/{id}/familymembers */ private saveClientFamilyMembers(clientId: string) { const members = this.clientFamilyMembersStep.clientFamilyMembers; for (const member of members) { 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: () => {} }); } } /** * Save & Next Confirmation Dialog */ @Component({ selector: 'mifosx-save-confirm-dialog', template: `
save

Save & Proceed

Client general details will be saved

Are you sure you want to save the client details and proceed to the next step?

info Once saved, a client record will be created. You can update the details later.
`, styles: [ ` .scd { background: #fff; border-radius: 12px; overflow: hidden; } .scd-header { display: flex; align-items: center; gap: 14px; padding: 20px 24px 16px; } .scd-header__icon-wrap { width: 44px; height: 44px; border-radius: 12px; background: #eff6ff; display: flex; align-items: center; justify-content: center; } .scd-header__icon { color: #1074b9; font-size: 24px; width: 24px; height: 24px; } .scd-header__title { margin: 0; font-size: 17px; font-weight: 700; color: #0f172a; } .scd-header__sub { margin: 2px 0 0; font-size: 12px; color: #64748b; } .scd-body { padding: 0 24px 20px; } .scd-body p { margin: 0 0 14px; font-size: 14px; color: #374151; line-height: 1.5; } .scd-info { display: flex; align-items: flex-start; gap: 8px; padding: 10px 14px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; font-size: 12px; color: #0c4a6e; } .scd-info__icon { font-size: 16px !important; width: 16px !important; height: 16px !important; color: #0284c7; flex-shrink: 0; margin-top: 1px; } .scd-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 24px; border-top: 1px solid #f1f5f9; background: #f8fafc; } .scd-cancel, .scd-confirm { display: flex !important; align-items: center; gap: 6px; font-size: 13px !important; height: 38px !important; border-radius: 8px !important; } .scd-confirm { font-weight: 600 !important; } ` ], imports: [ MatIcon, MatDialogClose ] }) export class SaveConfirmDialogComponent {} /** * Verify Client Required Alert Dialog */ @Component({ selector: 'mifosx-verify-client-alert-dialog', template: `
shield

Verification Required

Client name must be screened before saving

Please click {{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }} to screen the client name against the UN Security Council Consolidated Sanctions List before proceeding.

gavel RBI KYC Master Direction (2016) — Section 51 & PMLA 2002. Sanctions screening is mandatory before account opening.
`, styles: [ ` .vcad { background: #fff; border-radius: 12px; overflow: hidden; } .vcad-header { display: flex; align-items: center; gap: 14px; padding: 20px 24px 16px; } .vcad-header__icon-wrap { width: 44px; height: 44px; border-radius: 12px; background: #fef3c7; display: flex; align-items: center; justify-content: center; } .vcad-header__icon { color: #d97706; font-size: 24px; width: 24px; height: 24px; } .vcad-header__title { margin: 0; font-size: 17px; font-weight: 700; color: #0f172a; } .vcad-header__sub { margin: 2px 0 0; font-size: 12px; color: #64748b; } .vcad-body { padding: 0 24px 20px; } .vcad-body p { margin: 0 0 14px; font-size: 14px; color: #374151; line-height: 1.5; } .vcad-info { display: flex; align-items: flex-start; gap: 8px; padding: 10px 14px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; font-size: 12px; color: #7c2d12; } .vcad-info__icon { font-size: 16px !important; width: 16px !important; height: 16px !important; color: #d97706; flex-shrink: 0; margin-top: 1px; } .vcad-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 24px; border-top: 1px solid #f1f5f9; background: #f8fafc; } .vcad-ok { display: flex !important; align-items: center; gap: 6px; font-size: 13px !important; height: 38px !important; border-radius: 8px !important; font-weight: 600 !important; } ` ], imports: [ MatIcon, MatDialogClose ] }) export class VerifyClientAlertDialogComponent { data = inject(MAT_DIALOG_DATA); }