/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, Input, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIcon } from '@angular/material/icon'; import { MatIconButton, MatButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ClientsService } from '../../clients.service'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; import { Dates } from 'app/core/utils/dates'; @Component({ selector: 'mifosx-client-india-address-step', templateUrl: './client-india-address-step.component.html', styleUrls: ['./client-india-address-step.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, MatCheckbox, MatTabsModule, MatIcon, MatIconButton, MatButton ] }) export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { private fb = inject(UntypedFormBuilder); private http = inject(HttpClient); private clientsService = inject(ClientsService); private kycUpload = inject(KycUploadHelperService); private dialog = inject(MatDialog); private dates = inject(Dates); /** Client ID — when set, loads existing addresses for edit mode */ @Input() clientId: string = ''; /** Read-only mode — disables form editing until user clicks edit icon */ @Input() readOnly = false; /** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own * name — restricts the address-proof picker to Aadhaar/Passport when true. */ @Input() isMinor = false; /** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/ * NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered * office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */ @Input() isNonIndividual = false; /** KYC form data passed down from the parent wizard so we can pre-fill address proof */ @Input() kycData: any; /** KYC document code for robust matching */ @Input() kycDocCode: string | undefined; /** KYC document files passed down to avoid re-uploading */ @Input() kycDocFiles: { front: File | null; back: File | null } | null = null; /** Combined preview string for KYC document */ @Input() kycDocMergedPreview: string | null = null; /** Existing server documents from KYC step (used in Edit mode) */ @Input() existingKycDocSides: { front: any | null; back: any | null } | null = null; /** Identifier ID for existing server documents */ @Input() existingKycIdentifierId: string | null = null; activeTab = 0; sameAsPermanent = false; isAddressProofLinkedToKyc = false; permanentForm: UntypedFormGroup = this.fb.group({ addressLine1: [ '', Validators.required ], addressLine2: [''], pinCode: [ '', [ Validators.required, Validators.pattern(/^\d{6}$/) ] ], locality: [ '', Validators.required ], city: [''], district: [''], state: [ '', Validators.required ], country: [{ value: 'India', disabled: true }], landmark: [''], proofId: [ '', Validators.required ], documentKey: [''], issueDate: [''], expiryDate: [''] }); currentForm: UntypedFormGroup = this.fb.group({ addressLine1: [ '', Validators.required ], addressLine2: [''], pinCode: [ '', [ Validators.required, Validators.pattern(/^\d{6}$/) ] ], locality: [ '', Validators.required ], city: [''], district: [''], state: [ '', Validators.required ], country: [{ value: 'India', disabled: true }], landmark: [''], residenceType: [''], yearsAtAddress: [''] }); officeForm: UntypedFormGroup = this.fb.group({ orgName: [''], department: [''], addressLine1: [''], addressLine2: [''], pinCode: [ '', Validators.pattern(/^\d{6}$/) ], locality: [''], city: [''], district: [''], state: [''], country: [{ value: 'India', disabled: true }], officePhone: [''], employmentType: [''], employeeId: [''], yearsWithOrg: [''] }); correspondenceForm: UntypedFormGroup = this.fb.group({ addressLine1: [ '', Validators.required ], addressLine2: [''], pinCode: [ '', [ Validators.required, Validators.pattern(/^\d{6}$/) ] ], locality: [ '', Validators.required ], city: [''], district: [''], state: [ '', Validators.required ], country: [{ value: 'India', disabled: true }], landmark: [''], commMode: [''], contactNumber: [''] }); // PIN lookup state per tab pinStatus: Record = { permanent: { loading: false, found: false, error: false, message: '' }, current: { loading: false, found: false, error: false, message: '' }, office: { loading: false, found: false, error: false, message: '' }, correspondence: { loading: false, found: false, error: false, message: '' } }; localityOptions: Record = { permanent: [], current: [], office: [], correspondence: [] }; /** KYC address-proof picker state (mirrors identities-tab dialog) */ addressProofs: ProofType[] = []; addressGroups: { label: string; items: ProofType[] }[] = []; selectedAddrProof: ProofType | null = null; loadingProofs = false; addrFiles: { front: File | null; back: File | null } = { front: null, back: null }; /** Combined front+back PNG (built automatically once both image sides are ready) */ addrMergedFile: File | null = null; addrMergedPreview: string | null = null; /** * Identifier row + attached documents that back the address proof on the server (edit * mode). Populated by loadExistingAddressProof() so the UI can show the file the user * uploaded last time instead of an empty picker. */ existingAddressProofIdentifierId: string | null = null; existingAddressProofDocuments: { id: string; name: string; fileName: string; type: string; size: number; previewUrl: string | null; }[] = []; /** Set only when the saved documents are separately-uploaded " — Front"/"— Back" * files (the CKYC tab / client-kyc-step convention) rather than this component's own single * merged-PNG upload — lets the two slots show their own file instead of both showing * whichever one happened to be first. */ private existingAddrFrontPreview: string | null = null; private existingAddrBackPreview: string | null = null; residenceTypes = [ 'Owned', 'Rented', 'Leased', 'Company Provided', 'Parental / Family Owned' ]; employmentTypes = [ 'Salaried (Private)', 'Salaried (Government / PSU)', 'Self-Employed Professional', 'Business Owner', 'Retired' ]; commModes = [ 'Post / Courier', 'Email', 'SMS', 'WhatsApp' ]; private pinTimers: Record = {}; private pinCache: Record = {}; private addressLoaded = false; /** Address type name → tab mapping for loading existing data */ private typeToTab: Record = {}; private existingAddressProofCheckComplete = false; ngOnInit(): void { this.loadProofTypes(); if (this.clientId) { this.loadExistingAddresses(); } } private findMatchingProof(targetName: string | undefined): any { if (!targetName) return undefined; const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const n = cleanStr(targetName); return this.addressProofs.find((p) => { const pname = cleanStr(p.name); return pname === n || n.includes(pname) || pname.includes(n); }); } private tryLinkKycData(): void { if (!this.kycData || !this.kycData.kycDocumentName) return; if (this.clientId && !this.existingAddressProofCheckComplete) return; if (this.isAddressProofLinkedToKyc || !this.permanentForm.get('documentKey')?.value) { let matchedProof = undefined; if (this.kycDocCode) { matchedProof = this.addressProofs.find((p) => p.code === this.kycDocCode); } if (!matchedProof) { matchedProof = this.findMatchingProof(this.kycData.kycDocumentName); } if (matchedProof) { this.isAddressProofLinkedToKyc = true; this.selectAddrProof(matchedProof); this.permanentForm.patchValue({ documentKey: this.kycData.kycDocumentNumber, issueDate: this.kycData.kycDocIssueDate }); this.currentForm.patchValue({ documentKey: this.kycData.kycDocumentNumber, issueDate: this.kycData.kycDocIssueDate }); // Sync uploaded images if they exist in KYC step if (this.kycDocFiles && (this.kycDocFiles.front || this.kycDocFiles.back)) { this.addrFiles = { front: this.kycDocFiles.front || null, back: this.kycDocFiles.back || null }; if (this.kycDocMergedPreview) { this.addrMergedPreview = this.kycDocMergedPreview; } } else if (this.existingKycDocSides && (this.existingKycDocSides.front || this.existingKycDocSides.back)) { this.existingAddressProofDocuments = []; if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front); if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back); // These already carry their own per-side preview from the KYC step's own mapping — // keep the two slots distinct rather than collapsing them into one shared preview. this.existingAddrFrontPreview = this.existingKycDocSides.front?.previewUrl || null; this.existingAddrBackPreview = this.existingKycDocSides.back?.previewUrl || null; this.existingAddressProofIdentifierId = this.existingKycIdentifierId; } } else if (this.isAddressProofLinkedToKyc) { // If it was linked but the KYC document changed to something not allowed for address (e.g. PAN for non-individuals), unlink. this.unlinkAddressProof(); } } } unlinkAddressProof(): void { this.isAddressProofLinkedToKyc = false; this.permanentForm.patchValue({ documentKey: '', issueDate: null }); this.currentForm.patchValue({ documentKey: '', issueDate: null }); this.addrFiles = { front: null, back: null }; this.addrMergedPreview = null; this.existingAddrFrontPreview = null; this.existingAddrBackPreview = null; } /** * Resets transient address-tab state for a brand-new client. * * This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME * component instance alive across repeat visits to /clients/create, so `ngOnInit` never * re-runs for a second "create new client" session — the previous client's address forms, * selected proof-of-address chip and uploaded files would otherwise leak into the new one. * Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins. */ resetForNewClient(): void { this.permanentForm.reset({ country: 'India' }); this.currentForm.reset({ country: 'India' }); this.officeForm.reset({ country: 'India' }); this.correspondenceForm.reset({ country: 'India' }); this.activeTab = 0; this.sameAsPermanent = false; this.pinStatus = { permanent: { loading: false, found: false, error: false, message: '' }, current: { loading: false, found: false, error: false, message: '' }, office: { loading: false, found: false, error: false, message: '' }, correspondence: { loading: false, found: false, error: false, message: '' } }; this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] }; this.selectedAddrProof = null; this.addrFiles = { front: null, back: null }; this.addrMergedFile = null; this.addrMergedPreview = null; this.existingAddrFrontPreview = null; this.existingAddrBackPreview = null; this.existingAddressProofIdentifierId = null; this.existingAddressProofDocuments = []; this.addressLoaded = false; this.existingAddressProofCheckComplete = false; // Re-fetch rather than re-filter `addressProofs` in place — if the previous session's // isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering // that reduced list again can never recover the items it already removed. this.loadProofTypes(); } private loadProofTypes(): void { this.loadingProofs = true; this.clientsService.getKycProofTypes().subscribe({ next: (res: any) => { const activeFilter = (p: any) => p?.active !== false; this.addressProofs = this.filterAddressProofsForNonIndividual( this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType)) ); this.addressGroups = groupProofsByCategory(this.addressProofs); this.loadingProofs = false; this.tryLinkKycData(); }, error: () => { this.loadingProofs = false; } }); } /** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts, * employer letters or pension orders in their own name, and Voter ID/Driving Licence both * require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */ private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([ 'AADHAAR', 'PASSPORT' ]); private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] { if (!this.isMinor) return proofs; return proofs.filter((p) => ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase()) ); } /** Personal OVDs — not valid proof of a registered office/entity address. */ private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([ 'AADHAAR', 'PASSPORT', 'VOTER_ID', 'DRIVING_LICENSE', 'NREGA_JOB_CARD', 'NPR_LETTER', 'BIRTH_CERTIFICATE', 'SCHOOL_ID_CARD' ]); private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] { if (!this.isNonIndividual) return proofs; return proofs.filter( (p) => !ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase()) ); } selectAddrProof(proof: ProofType): void { this.selectedAddrProof = proof; this.permanentForm.get('proofId')?.setValue(proof.id); this.addrFiles = { front: null, back: null }; this.addrMergedFile = null; this.addrMergedPreview = null; } isAddrProofSelected(proof: ProofType): boolean { return this.selectedAddrProof?.id === proof.id; } async onAddrFile(event: Event, slot: 'front' | 'back'): Promise { const input = event.target as HTMLInputElement; const raw = input.files?.[0]; input.value = ''; if (!raw) return; if (raw.size > 5 * 1024 * 1024) return; const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`); if (!cropped) return; this.addrFiles[slot] = cropped; await this.refreshAddrMerged(); } async reCropAddrFile(slot: 'front' | 'back'): Promise { const current = this.addrFiles[slot]; if (!current || !current.type.startsWith('image/')) return; const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`); if (!cropped) return; this.addrFiles[slot] = cropped; await this.refreshAddrMerged(); } removeAddrFile(slot: 'front' | 'back'): void { this.addrFiles[slot] = null; this.addrMergedFile = null; this.addrMergedPreview = null; } private async refreshAddrMerged(): Promise { const proof = this.selectedAddrProof; if (!proof || proof.sides !== 2 || !this.addrFiles.front || !this.addrFiles.back) { this.addrMergedFile = null; this.addrMergedPreview = null; return; } const merged = await this.kycUpload.mergeFrontBack( this.addrFiles.front, this.addrFiles.back, proof.name || 'address-proof' ); if (merged) { this.addrMergedFile = merged.file; this.addrMergedPreview = merged.preview; } else { this.addrMergedFile = null; this.addrMergedPreview = null; } } /** * Files + proof metadata for the parent to persist (e.g. via client identifier API). * When both image sides are present they're sent as a single combined PNG. */ get addressProofPayload(): { proof: ProofType | null; proofId: any; documentKey: string; issueDate: string; expiryDate: string; files: { slot: string; file: File }[]; } { const files: { slot: string; file: File }[] = []; if (this.selectedAddrProof?.sides === 2 && this.addrMergedFile) { files.push({ slot: 'Combined', file: this.addrMergedFile }); } else { if (this.addrFiles.front) files.push({ slot: 'Front', file: this.addrFiles.front }); if (this.selectedAddrProof?.sides === 2 && this.addrFiles.back) { files.push({ slot: 'Back', file: this.addrFiles.back }); } } const rawIssueDate = this.permanentForm.get('issueDate')?.value; const rawExpiryDate = this.permanentForm.get('expiryDate')?.value; return { proof: this.selectedAddrProof, proofId: this.permanentForm.get('proofId')?.value || '', documentKey: this.permanentForm.get('documentKey')?.value || '', issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '', expiryDate: rawExpiryDate ? this.dates.getDate(rawExpiryDate) : '', files }; } ngOnChanges(changes: SimpleChanges): void { if (changes['kycData'] || changes['existingKycDocSides']) { this.tryLinkKycData(); } if (changes['existingKycIdentifierId']) { if ( this.existingAddressProofIdentifierId && this.existingAddressProofIdentifierId === this.existingKycIdentifierId ) { this.isAddressProofLinkedToKyc = true; } } if (changes['readOnly']) { const forms = [ this.permanentForm, this.currentForm, this.officeForm, this.correspondenceForm ]; for (const form of forms) { if (this.readOnly) { form.disable({ emitEvent: false }); } else { form.enable({ emitEvent: false }); } } } if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) { const filtered = this.filterAddressProofsForMinor(this.addressProofs); if (filtered.length !== this.addressProofs.length) { this.addressProofs = filtered; this.addressGroups = groupProofsByCategory(this.addressProofs); if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) { this.selectedAddrProof = null; this.permanentForm.get('proofId')?.setValue(''); } } } // Legal form toggled on an already-initialized instance — re-fetch rather than filter // `addressProofs` in place, since the non-individual filter only ever removes entries and // toggling back to individual could never recover what a prior pass had stripped out. if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) { this.selectedAddrProof = null; this.permanentForm.get('proofId')?.setValue(''); this.loadProofTypes(); } } /** * Load existing addresses from Fineract API and populate forms. * Maps Fineract fields back to India address fields. */ private loadExistingAddresses(): void { if (this.addressLoaded) return; this.clientsService.getClientAddressData(this.clientId).subscribe({ next: (addresses: any[]) => { this.addressLoaded = true; if (!addresses?.length) return; // Build type-to-tab mapping from address type names const tabMap: Record = { permanent: 'permanent', 'permanent address': 'permanent', residential: 'current', current: 'current', 'current / residential': 'current', communication: 'current', office: 'office', 'office / work': 'office', business: 'office', work: 'office', correspondence: 'correspondence', mailing: 'correspondence', postal: 'correspondence' }; for (const addr of addresses) { const typeName = (addr.addressType || '').toLowerCase().trim(); const tab = tabMap[typeName] || (addresses.indexOf(addr) === 0 ? 'permanent' : null); if (!tab) continue; const form = this.getForm(tab); form.patchValue({ addressLine1: addr.addressLine1 || '', addressLine2: addr.addressLine2 || '', pinCode: addr.postalCode || '', locality: addr.townVillage || '', city: addr.city || '', district: addr.countyDistrict || '', state: addr.stateName || '', landmark: addr.street || '' }); // Saved address payloads don't include the PIN's full locality list // or the "verified" status — so the dropdown shows only the saved // value and the green "PIN verified" chip never appears. Re-run the // lookup silently to repopulate options and confirm the PIN, without // overwriting what the user already had on record. if ((addr.postalCode || '').toString().length === 6) { this.silentlyHydratePin(tab); } } // Surface the address proof file the user uploaded previously. this.loadExistingAddressProof(); }, error: () => { this.addressLoaded = true; } }); } /** * Address proof on this UI is persisted as a client_identifier row whose documentType * is one of the address-proof types (see create-client.saveAddressProof). To restore * the picker state and show the existing file in edit mode we: * 1. List the client's identifiers * 2. Find the one whose documentType matches an entry in `addressProofs` * 3. Patch the permanent-form proofId / documentKey from it * 4. Fetch its attached documents and render the first image as a preview */ private loadExistingAddressProof(): void { if (!this.clientId) return; // Wait briefly for proof types to load if they're still in flight — they're needed // to filter identifiers down to "address proofs" only. if (this.loadingProofs || this.addressProofs.length === 0) { setTimeout(() => this.loadExistingAddressProof(), 200); return; } this.clientsService.getClientIdentifiers(this.clientId).subscribe({ next: (identifiers: any[]) => { if (!identifiers || identifiers.length === 0) { this.existingAddressProofCheckComplete = true; this.tryLinkKycData(); return; } // Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for both this slot and // the separate identity-proof one (client-kyc-step) — when a client has two such // dual-purpose identifiers, that step always runs first and picks the OLDEST match (the // one on file first, historically the identity proof), so picking the NEWEST match here // lands this step on the other row without the two steps needing to coordinate. const identifier = [...identifiers] .sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0)) .find((i: any) => this.findMatchingProof(i?.documentType?.name)); if (!identifier?.id) { this.existingAddressProofCheckComplete = true; this.tryLinkKycData(); return; } this.existingAddressProofCheckComplete = true; this.existingAddressProofIdentifierId = identifier.id.toString(); if (this.existingKycIdentifierId && this.existingKycIdentifierId === this.existingAddressProofIdentifierId) { this.isAddressProofLinkedToKyc = true; } // Restore selected chip + form values const matchedProof = this.findMatchingProof(identifier.documentType?.name); if (matchedProof) { this.selectedAddrProof = matchedProof; this.permanentForm.patchValue({ proofId: matchedProof.id, documentKey: identifier.documentKey || '' }); // The description carries "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" when set on // create (see create-client.saveAddressProof / CkycTabComponent) — pull both out so the // form's date fields show what was previously entered. Each match stops at the next // comma so adding expiry after issue doesn't get swallowed into the issue-date capture. const desc: string = identifier.description || ''; const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i); const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i); if (issueMatch) { this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) }); } if (expiryMatch) { this.permanentForm.patchValue({ expiryDate: this.dates.parseDate(expiryMatch[1].trim()) }); } } this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({ next: (docs: any[]) => { if (!docs || docs.length === 0) return; this.existingAddressProofDocuments = docs.map((d: any) => ({ id: d.id?.toString() || '', name: d.name || '', fileName: d.fileName || '', type: d.type || '', size: d.size || 0, previewUrl: null as string | null })); const sideOf = (d: { name: string; fileName: string }): 'front' | 'back' | null => { const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase(); if (text.includes('back')) return 'back'; if (text.includes('front')) return 'front'; return null; }; // Changing the document type (e.g. Aadhaar -> Passport) keeps the old front/back // files attached to this same identifier rather than removing them, so there can be // more than one "— Front"/"— Back" pair on file — sort newest-first before matching // or this would keep showing the very first scan ever uploaded here. const byNewest = [...this.existingAddressProofDocuments].sort((a, b) => Number(b.id) - Number(a.id)); const frontDoc = byNewest.find((d) => sideOf(d) === 'front'); const backDoc = byNewest.find((d) => sideOf(d) === 'back'); if (frontDoc || backDoc) { // Separately-uploaded front/back — each side gets its own preview. if (frontDoc) this.downloadAddrSidePreview(frontDoc, 'front'); if (backDoc) this.downloadAddrSidePreview(backDoc, 'back'); return; } // Legacy/single-file case: one combined (or unlabelled) document covers both slots. const firstImage = this.existingAddressProofDocuments.find((d) => (d.type || '').toLowerCase().startsWith('image/') ); if (firstImage) { this.clientsService .downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, firstImage.id) .subscribe({ next: async (blob: any) => { const buffer = await blob.arrayBuffer(); const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary); if (!this.addrMergedPreview) { this.addrMergedPreview = firstImage.previewUrl; } }, error: () => {} }); } }, error: () => {} }); }, error: () => {} }); } private downloadAddrSidePreview(doc: { id: string; fileName: string; name: string }, slot: 'front' | 'back'): void { this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, doc.id).subscribe({ next: async (blob: any) => { const buffer = await blob.arrayBuffer(); const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } const url = 'data:' + blob.type + ';base64,' + btoa(binary); if (slot === 'front') this.existingAddrFrontPreview = url; else this.existingAddrBackPreview = url; }, error: () => {} }); } /** What the Front slot should show: its own saved file when front/back were uploaded * separately, falling back to the single combined preview for legacy/merged uploads. */ get existingAddrFrontSlotPreview(): string | null { return this.existingAddrFrontPreview || this.addrMergedPreview; } get existingAddrFrontSlotName(): string { const front = this.existingAddressProofDocuments.find((d) => `${d.name} ${d.fileName}`.toLowerCase().includes('front') ); const doc = front || this.existingAddressProofDocuments[0]; return doc?.fileName || doc?.name || ''; } /** Same as above, for the Back slot. */ get existingAddrBackSlotPreview(): string | null { return this.existingAddrBackPreview || this.addrMergedPreview; } get existingAddrBackSlotName(): string { const back = this.existingAddressProofDocuments.find((d) => `${d.name} ${d.fileName}`.toLowerCase().includes('back') ); const doc = back || this.existingAddressProofDocuments[0]; return doc?.fileName || doc?.name || ''; } onPinInput(tab: string) { const form = this.getForm(tab); const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || ''; form.get('pinCode')?.setValue(pin, { emitEvent: false }); clearTimeout(this.pinTimers[tab]); if (pin.length < 6) { this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' }; this.clearPinFields(tab); return; } if (this.pinCache[pin]) { this.applyPinData(tab, this.pinCache[pin]); return; } this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' }; this.pinTimers[tab] = setTimeout(() => { this.http.get(`https://api.postalpincode.in/pincode/${pin}`).subscribe({ next: (data: any) => { if (data[0]?.Status === 'Success') { this.pinCache[pin] = data[0].PostOffice; this.applyPinData(tab, data[0].PostOffice); this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Found' }; } else { this.clearPinFields(tab); this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Invalid PIN' }; } }, error: () => { this.pinStatus[tab] = { loading: false, found: false, error: true, message: 'Network error' }; } }); }, 550); } private applyPinData(tab: string, offices: any[]) { const seen = new Set(); this.localityOptions[tab] = offices .filter((o) => { if (seen.has(o.Name)) return false; seen.add(o.Name); return true; }) .map((o) => o.Name); const form = this.getForm(tab); const f = offices[0]; form.patchValue({ locality: this.localityOptions[tab][0] || '', city: f.Block || f.Division || f.District || '', district: f.District || '', state: f.State || '' }); } /** Open the saved address proof in an in-app preview dialog. */ viewExistingAddressProof(): void { if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) { return; } const doc = this.existingAddressProofDocuments[0]; this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({ next: (blob: Blob) => this.dialog.open(DocumentPreviewDialogComponent, { data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name } }), error: () => { // Silent fail — UI shows the saved name so user knows it exists. } }); } private clearPinFields(tab: string) { this.localityOptions[tab] = []; const form = this.getForm(tab); form.patchValue({ locality: '', city: '', district: '', state: '' }); } /** * Re-hydrate the PIN lookup for an already-loaded address. * * Used when an existing client opens in edit mode — the form values are * patched from the server, but the locality dropdown options and the * "PIN verified" status are derived state that only `onPinInput` produces. * This variant populates that derived state WITHOUT clobbering the saved * locality / city / district / state the user originally chose. */ private silentlyHydratePin(tab: string): void { const form = this.getForm(tab); const pin = (form.get('pinCode')?.value || '').toString().replace(/\D/g, ''); if (pin.length !== 6) return; const applyOptions = (offices: any[]) => { const seen = new Set(); const opts = (offices || []) .filter((o) => { if (!o?.Name || seen.has(o.Name)) return false; seen.add(o.Name); return true; }) .map((o) => o.Name); this.localityOptions[tab] = opts; this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Verified' }; // If the saved locality isn't part of the options (rare — e.g. spelling // mismatch), prepend it so the bound `` still resolves. const savedLocality = form.get('locality')?.value; if (savedLocality && !opts.includes(savedLocality)) { this.localityOptions[tab] = [ savedLocality, ...opts ]; } // Fineract's address payload returns `stateProvinceId` (numeric) rather // than `stateName`, so the State field arrives empty after load. // Fill state / city / district from the PIN lookup ONLY when they're // currently empty — preserves anything the user has actually saved. const first = (offices || [])[0]; if (first) { const patch: Record = {}; if (!form.get('state')?.value && first.State) patch['state'] = first.State; if (!form.get('city')?.value && (first.Block || first.Division || first.District)) { patch['city'] = first.Block || first.Division || first.District; } if (!form.get('district')?.value && first.District) patch['district'] = first.District; if (Object.keys(patch).length > 0) { form.patchValue(patch); } } }; if (this.pinCache[pin]) { applyOptions(this.pinCache[pin]); return; } this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' }; this.http.get(`https://api.postalpincode.in/pincode/${pin}`).subscribe({ next: (data: any) => { if (data?.[0]?.Status === 'Success') { this.pinCache[pin] = data[0].PostOffice; applyOptions(data[0].PostOffice); } else { // Couldn't confirm — keep the saved value visible but flag it. this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' }; } }, error: () => { this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' }; } }); } getForm(tab: string): UntypedFormGroup { switch (tab) { case 'permanent': return this.permanentForm; case 'current': return this.currentForm; case 'office': return this.officeForm; case 'correspondence': return this.correspondenceForm; default: return this.permanentForm; } } toggleSameAsPermanent(checked: boolean) { this.sameAsPermanent = checked; if (checked) { const pv = this.permanentForm.getRawValue(); this.currentForm.patchValue({ addressLine1: pv.addressLine1, addressLine2: pv.addressLine2, pinCode: pv.pinCode, locality: pv.locality, city: pv.city, district: pv.district, state: pv.state, landmark: pv.landmark }); this.localityOptions['current'] = [...this.localityOptions['permanent']]; this.pinStatus['current'] = { ...this.pinStatus['permanent'] }; } } copyFrom(source: string) { const srcForm = this.getForm(source); const sv = srcForm.getRawValue(); this.correspondenceForm.patchValue({ addressLine1: sv.addressLine1, addressLine2: sv.addressLine2, pinCode: sv.pinCode, locality: sv.locality, city: sv.city, district: sv.district, state: sv.state, landmark: sv.landmark || '' }); this.localityOptions['correspondence'] = [...(this.localityOptions[source] || [])]; this.pinStatus['correspondence'] = { ...this.pinStatus[source] }; } /** Returns all address data for submission */ get addressData(): any { return { permanent: this.permanentForm.getRawValue(), current: this.sameAsPermanent ? { ...this.permanentForm.getRawValue(), sameAsPermanent: true } : this.currentForm.getRawValue(), office: this.officeForm.getRawValue(), correspondence: this.correspondenceForm.getRawValue() }; } get hasAddressData(): boolean { return this.permanentForm.get('addressLine1')?.value?.length > 0; } }