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>
1033 lines
38 KiB
TypeScript
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;
|
|
}
|
|
}
|