/** * 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; 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; 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) { return 'Client failed sanctions screening — cannot proceed.'; } 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 }) .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) { 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; 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'; // 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; 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 targetName = (proofName || '').trim().toLowerCase(); const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName); 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 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 {}