Files
cbsnexora/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts
T
niteshandClaude Sonnet 5 31e99b1363 fix(clients): rebuild CKYC document upload as a popup with full proof details
The CKYC tab's document cards now open a dedicated dialog (with client-side
preview) instead of expanding inline, collecting document type, number, and
issue/expiry dates the same way client creation does. Fixes several bugs
found along the way:

- Uploading a document type that's valid for both identity and address
  (Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint"
  and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are
  both resolved.
- Edit Client's Identity-proof and Address-proof steps no longer show each
  other's document when both are dual-purpose OVD types, and no longer show
  stale/mismatched front-back scans after a document type change.
- Address-proof's own document preview never showed a document at all when
  no m_client_kyc record existed yet.

Document number and issue/expiry dates are persisted on the identifier
(documentKey + a shared "Issue date: ..., Expiry date: ..." description
convention) and read back by both the CKYC tab and Edit Client so the same
details appear everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:39:14 +05:30

1033 lines
38 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
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<string, { loading: boolean; found: boolean; error: boolean; message: string }> = {
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<string, string[]> = {
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 "<Proof> — 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<string, any> = {};
private pinCache: Record<string, any[]> = {};
private addressLoaded = false;
/** Address type name → tab mapping for loading existing data */
private typeToTab: Record<string, string> = {};
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<void> {
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<void> {
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<void> {
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<string, string> = {
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<any[]>(`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<string>();
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<string>();
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 `<mat-option>` 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<string, string> = {};
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<any[]>(`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;
}
}