Files
cbsnexora/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts
T
2026-07-28 10:45:31 +05:30

782 lines
26 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 { ClientsService } from '../../clients.service';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
@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);
/** 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;
activeTab = 0;
sameAsPermanent = 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: ['']
});
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;
}[] = [];
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> = {};
ngOnInit(): void {
this.loadProofTypes();
if (this.clientId) {
this.loadExistingAddresses();
}
}
private loadProofTypes(): void {
this.loadingProofs = true;
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = this.filterAddressProofsForMinor(
(res?.addressProofs || []).filter(activeFilter).map(normalizeProofType)
);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
},
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())
);
}
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;
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 });
}
}
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: this.permanentForm.get('issueDate')?.value || '',
files
};
}
ngOnChanges(changes: SimpleChanges): void {
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('');
}
}
}
}
/**
* 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) return;
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
const identifier = identifiers.find((i: any) => {
const n = i?.documentType?.name?.toLowerCase();
return n && proofNames.has(n);
});
if (!identifier?.id) return;
this.existingAddressProofIdentifierId = identifier.id.toString();
// Restore selected chip + form values
const matchedProof = this.addressProofs.find(
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
);
if (matchedProof) {
this.selectedAddrProof = matchedProof;
this.permanentForm.patchValue({
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
// create-client.saveAddressProof) — pull it out so the form's issueDate field
// shows what was previously entered.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
if (issueMatch) {
this.permanentForm.patchValue({ issueDate: issueMatch[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 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: () => {}
});
}
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 a new browser tab.
*
* For images we already hold a base64 data URL in `previewUrl` from
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
* For PDFs / other types we need to fetch the binary blob and create a
* temporary object URL so the browser's PDF viewer can render it.
*/
viewExistingAddressProof(): void {
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
return;
}
const doc = this.existingAddressProofDocuments[0];
if (doc.previewUrl) {
window.open(doc.previewUrl, '_blank', 'noopener');
return;
}
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
// Revoke after the tab has had time to load — browsers vary, so
// err on the side of a generous wait.
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
},
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;
}
}