782 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|