/** * 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, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatTooltip } from '@angular/material/tooltip'; import { HttpClient } from '@angular/common/http'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export interface EditAddressDialogData { /** 'add' shows Address Type select + Copy-from picker; 'edit' hides them. */ mode?: 'add' | 'edit'; title: string; /** Existing address payload (edit) or null (add). */ address: any; template: { addressTypeIdOptions?: { id: number; name: string }[]; stateProvinceIdOptions?: { id: number; name: string }[]; countryIdOptions?: { id: number; name: string }[]; [k: string]: any; }; /** Field-config rows from `clientAddressFieldConfig`; used to honour * the org's address-field visibility / enabling settings. */ fieldConfig: { field: string; isEnabled: boolean }[]; /** All currently-saved addresses for this client. In 'add' mode the user can * pick one as the source for "Copy from existing address". */ existingAddresses?: any[]; } interface PinPostOffice { Name: string; Block?: string; Division?: string; District?: string; State?: string; } /** * Edit Client Address dialog (post-create, in client view). * * Mirrors the create-stepper's address experience: * • Postal Code field auto-triggers a PIN lookup * • Locality / Area becomes a dropdown of post offices for that PIN * • City / District / State auto-fill from the lookup (only when empty) * • State resolves to the matching `stateProvinceId` from the template * * On submit, returns the form value via `dialogRef.close({ value })`. */ @Component({ selector: 'mifosx-edit-address-dialog', templateUrl: './edit-address-dialog.component.html', styleUrls: ['./edit-address-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, CommonModule, ReactiveFormsModule, FormsModule, FaIconComponent, MatTooltip ] }) export class EditAddressDialogComponent implements OnInit { private fb = inject(UntypedFormBuilder); private http = inject(HttpClient); public dialogRef = inject(MatDialogRef); public data = inject(MAT_DIALOG_DATA); form!: UntypedFormGroup; localityOptions: string[] = []; pinStatus: { loading: boolean; found: boolean; error: boolean; message: string } = { loading: false, found: false, error: false, message: '' }; /** Currently-selected source for "Copy from existing address" (id). */ copyFromAddressId: number | '' = ''; private pinTimer: any = null; private pinCache: Record = {}; get isAdd(): boolean { return this.data.mode === 'add'; } get stateOptions() { return this.data.template?.stateProvinceIdOptions || []; } get countryOptions() { return this.data.template?.countryIdOptions || []; } get addressTypeOptions() { return this.data.template?.addressTypeIdOptions || []; } /** Existing addresses that can be a "Copy from" source (excludes empties). */ get copySourceOptions(): any[] { return (this.data.existingAddresses || []).filter((a) => a && (a.addressLine1 || a.postalCode)); } ngOnInit(): void { const a = this.data.address || {}; // Pre-resolve the saved stateProvinceId (numeric, from Fineract) to its // display name so the text field shows something meaningful on load. const savedStateName = a.stateName || a.stateProvinceName || (a.stateProvinceId ? (this.stateOptions.find((s) => s.id === a.stateProvinceId)?.name ?? '') : ''); const savedCountryName = a.countryName || (a.countryId ? (this.countryOptions.find((c) => c.id === a.countryId)?.name ?? '') : ''); this.form = this.fb.group({ ...(this.isAdd ? { addressType: [ a.addressType || '', Validators.required ] } : {}), postalCode: [ a.postalCode || '', [ Validators.required, Validators.pattern(/^\d{6}$/) ] ], street: [a.street || ''], addressLine1: [a.addressLine1 || ''], addressLine2: [a.addressLine2 || ''], addressLine3: [a.addressLine3 || ''], townVillage: [a.townVillage || ''], city: [a.city || ''], countyDistrict: [a.countyDistrict || ''], // State + Country are plain TEXT now (matching the create-stepper). On // submit we resolve them to the matching template id when possible — // see `submit()`. We also keep the raw original ids around so we don't // lose them if the user doesn't touch the fields. state: [savedStateName], country: [savedCountryName || 'India'], // Hidden originals — used on submit when text wasn't changed. _origStateProvinceId: [a.stateProvinceId || ''], _origCountryId: [a.countryId || ''] }); // If we already have a PIN on load, re-run the lookup so the locality // dropdown is populated and "PIN verified" badge shows in edit mode. if ((a.postalCode || '').toString().length === 6) { this.runPinLookup(true); } } /** * Copy address fields from an existing address (selected via the dropdown). * The Address Type is preserved — the user's current selection wins, since * the whole point of "Add" is creating a NEW address type. */ onCopyFromAddress(): void { const src = this.copySourceOptions.find((a) => String(a.addressId) === String(this.copyFromAddressId)); if (!src) return; const srcStateName = src.stateName || src.stateProvinceName || (src.stateProvinceId ? (this.stateOptions.find((s) => s.id === src.stateProvinceId)?.name ?? '') : ''); const srcCountryName = src.countryName || (src.countryId ? (this.countryOptions.find((c) => c.id === src.countryId)?.name ?? 'India') : 'India'); const patch: Record = { postalCode: src.postalCode || '', street: src.street || '', addressLine1: src.addressLine1 || '', addressLine2: src.addressLine2 || '', addressLine3: src.addressLine3 || '', townVillage: src.townVillage || '', city: src.city || '', countyDistrict: src.countyDistrict || '', state: srcStateName, country: srcCountryName, _origStateProvinceId: src.stateProvinceId || '', _origCountryId: src.countryId || '' }; this.form.patchValue(patch); // Re-run the PIN lookup so the locality dropdown options + "PIN verified" // chip light up for the copied PIN. if ((patch['postalCode'] || '').toString().length === 6) { this.runPinLookup(true); } } isFieldEnabled(field: string): boolean { const cfg = (this.data.fieldConfig || []).find((c) => c.field === field); return cfg ? !!cfg.isEnabled : true; } /** PIN input: debounce + lookup. */ onPinInput(): void { clearTimeout(this.pinTimer); const pin = (this.form.get('postalCode')?.value || '').toString().replace(/\D/g, ''); this.form.get('postalCode')?.setValue(pin, { emitEvent: false }); if (pin.length < 6) { this.pinStatus = { loading: false, found: false, error: false, message: '' }; this.localityOptions = []; return; } this.pinTimer = setTimeout(() => this.runPinLookup(false), 550); } /** * Fetch the post-office list for the current PIN and apply it. * @param preserve when true, don't overwrite existing values for * locality / city / district / state (used on load). */ private runPinLookup(preserve: boolean): void { const pin = (this.form.get('postalCode')?.value || '').toString(); if (pin.length !== 6) return; if (this.pinCache[pin]) { this.applyPinData(this.pinCache[pin], preserve); this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' }; return; } this.pinStatus = { loading: true, found: false, error: false, message: 'Looking up...' }; this.http.get(`https://api.postalpincode.in/pincode/${pin}`).subscribe({ next: (data: any) => { if (data?.[0]?.Status === 'Success') { this.pinCache[pin] = data[0].PostOffice; this.applyPinData(data[0].PostOffice, preserve); this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' }; } else { this.localityOptions = []; this.pinStatus = { loading: false, found: false, error: true, message: 'Invalid PIN' }; } }, error: () => { this.pinStatus = { loading: false, found: false, error: true, message: 'Network error' }; } }); } private applyPinData(offices: PinPostOffice[], preserve: boolean): void { const seen = new Set(); this.localityOptions = (offices || []) .filter((o) => { if (!o?.Name || seen.has(o.Name)) return false; seen.add(o.Name); return true; }) .map((o) => o.Name); const first = offices?.[0]; if (!first) return; const patch: Record = {}; // Town / Village — pick the first locality from the lookup. Skip only when // there's already a saved value AND we're in preserve mode. if (!preserve || !this.form.get('townVillage')?.value) { if (this.localityOptions[0]) patch['townVillage'] = this.localityOptions[0]; } // City — Block / Division / District fallback chain if (!preserve || !this.form.get('city')?.value) { const cityValue = first.Block || first.Division || first.District || ''; if (cityValue) patch['city'] = cityValue; } // District if (!preserve || !this.form.get('countyDistrict')?.value) { if (first.District) patch['countyDistrict'] = first.District; } // State — stored as plain TEXT in the form (same as the create wizard). // We display whatever the PIN service returns ("Madhya Pradesh", "Kerala", // etc.) without forcing it through the small Fineract dropdown which // sometimes only carries a handful of states. if (!preserve || !this.form.get('state')?.value) { if (first.State) patch['state'] = first.State; } // Country — postalpincode.in only serves India. Default to "India" text. if (!preserve || !this.form.get('country')?.value) { patch['country'] = 'India'; } if (Object.keys(patch).length > 0) { this.form.patchValue(patch); } } /** Lowercase, collapse internal whitespace, trim — for fuzzy name matching. */ private normalize(s: string | undefined | null): string { return (s || '').toString().toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim(); } /** Preserve the saved locality even if it's not in the lookup results. */ get effectiveLocalityOptions(): string[] { const saved = this.form?.get('townVillage')?.value; if (saved && !this.localityOptions.includes(saved)) { return [ saved, ...this.localityOptions ]; } return this.localityOptions; } submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } // Build the API payload — Fineract expects `stateProvinceId` (numeric id) // and `countryId`, not the text names we've been displaying. Resolve via // the template options; fall back to the original ids if the user didn't // change the field. const raw = this.form.value; const payload: Record = { postalCode: raw.postalCode, street: raw.street, addressLine1: raw.addressLine1, addressLine2: raw.addressLine2, addressLine3: raw.addressLine3, townVillage: raw.townVillage, city: raw.city, countyDistrict: raw.countyDistrict }; if (raw.addressType) payload['addressType'] = raw.addressType; const stateMatch = raw.state ? this.stateOptions.find((s) => this.normalize(s.name) === this.normalize(raw.state)) : null; payload['stateProvinceId'] = stateMatch ? stateMatch.id : raw._origStateProvinceId || ''; const countryMatch = raw.country ? this.countryOptions.find((c) => this.normalize(c.name) === this.normalize(raw.country)) : null; payload['countryId'] = countryMatch ? countryMatch.id : raw._origCountryId || ''; this.dialogRef.close({ value: payload }); } cancel(): void { this.dialogRef.close(); } }