Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion src/app/clients/clients-view/address-tab/address-tab.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

/** Angular Imports */
import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
Expand All @@ -19,6 +20,12 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen

/** Custom Services */
import { TranslateService } from '@ngx-translate/core';
import { PostalCodeLookupService } from 'app/shared/services/postal-code-lookup.service';
import { ResolvedAddress } from 'app/shared/models/postal-code-lookup.model';

/** rxjs Imports */
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { of, Subscription } from 'rxjs';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
Expand Down Expand Up @@ -56,6 +63,7 @@ export class AddressTabComponent {
private clientService = inject(ClientsService);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private postalCodeLookup = inject(PostalCodeLookupService);

/** Client Address Data */
clientAddressData: any;
Expand Down Expand Up @@ -97,6 +105,7 @@ export class AddressTabComponent {
formfields: this.getAddressFormFields('add')
};
const addAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
this.setupPostalCodeLookup(addAddressDialogRef);
addAddressDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
this.clientService
Expand Down Expand Up @@ -129,6 +138,7 @@ export class AddressTabComponent {
layout: { addButtonText: 'Edit' }
};
const editAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
this.setupPostalCodeLookup(editAddressDialogRef);
editAddressDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
const addressData = response.data.value;
Expand All @@ -145,6 +155,139 @@ export class AddressTabComponent {
});
}

/** Tracks which fields were auto-filled by the postal code lookup. */
private autoFilledFields = new Set<string>();

/**
* Sets up postal code auto-lookup on the dialog form.
* Subscribes to postalCode valueChanges, queries the lookup API,
* and auto-fills City, State/Province, and Country fields.
*/
private setupPostalCodeLookup(dialogRef: MatDialogRef<FormDialogComponent>) {
if (!this.postalCodeLookup.enabled) return;

let postalSub: Subscription;

dialogRef.afterOpened().subscribe(() => {
const form: UntypedFormGroup = dialogRef.componentInstance.form;
const postalCodeControl = form.get('postalCode');
if (!postalCodeControl) return;

// Capture the initial country value so we can distinguish
// user-selected countries from auto-filled ones.
const initialCountryId = form.get('countryId')?.value || null;

postalSub = postalCodeControl.valueChanges
.pipe(
debounceTime(600),
distinctUntilChanged(),
switchMap((value: string) => {
if (!value || value.trim().length < 3) {
return of(null);
}
const postalCode = value.trim();
// Only use the country for lookup if the user explicitly selected it
// (i.e. it was set initially or the user changed it manually).
const currentCountryId = form.get('countryId')?.value;
const isUserSelectedCountry =
currentCountryId && (currentCountryId === initialCountryId || !this.autoFilledFields.has('countryId'));

if (isUserSelectedCountry) {
const countryCode = this.getSelectedCountryCode(form);
if (countryCode) {
return this.postalCodeLookup.lookup(countryCode, postalCode);
}
}
return this.postalCodeLookup.lookupWithFallback(postalCode);
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
.subscribe((response) => {
if (!response) {
this.clearAutoFilledFields(form);
return;
}
const resolved = this.postalCodeLookup.toResolvedAddress(response);
if (resolved) {
this.clearAutoFilledFields(form);
this.applyResolvedAddress(form, resolved);
} else {
this.clearAutoFilledFields(form);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

dialogRef.afterClosed().subscribe(() => {
postalSub?.unsubscribe();
this.autoFilledFields.clear();
});
}

/**
* Gets the ISO country code for the currently selected country in the form.
*/
private getSelectedCountryCode(form: UntypedFormGroup): string | null {
const countryIdValue = form.get('countryId')?.value;
if (!countryIdValue) return null;

const selectedCountry = this.clientAddressTemplate.countryIdOptions?.find((c: any) => c.id === countryIdValue);
if (!selectedCountry) return null;

return this.postalCodeLookup.resolveCountryCode(selectedCountry.name);
}

/**
* Applies the resolved address to the form fields.
* Passes both full name and abbreviation to maximize match chances
* against Fineract's configured code values.
*/
/**
* Clears form fields that were previously set by auto-fill,
* so stale data doesn't persist when a new lookup fails or returns different results.
*/
private clearAutoFilledFields(form: UntypedFormGroup) {
for (const fieldName of this.autoFilledFields) {
const control = form.get(fieldName);
if (control) {
control.setValue(fieldName === 'city' ? '' : '');
control.markAsDirty();
}
}
this.autoFilledFields.clear();
}

private applyResolvedAddress(form: UntypedFormGroup, address: ResolvedAddress) {
const cityControl = form.get('city');
if (cityControl && address.city) {
cityControl.setValue(address.city);
cityControl.markAsDirty();
this.autoFilledFields.add('city');
}

const stateControl = form.get('stateProvinceId');
if (stateControl && (address.state || address.stateAbbreviation)) {
const stateOptions = this.clientAddressTemplate.stateProvinceIdOptions ?? [];
const matched = this.postalCodeLookup.findBestMatch(stateOptions, address.state, address.stateAbbreviation);
if (matched) {
stateControl.setValue(matched.id);
stateControl.markAsDirty();
this.autoFilledFields.add('stateProvinceId');
}
}

const countryControl = form.get('countryId');
if (countryControl && (address.country || address.countryAbbreviation)) {
const countryOptions = this.clientAddressTemplate.countryIdOptions ?? [];
const matched = this.postalCodeLookup.findBestMatch(countryOptions, address.country, address.countryAbbreviation);
if (matched) {
countryControl.setValue(matched.id);
countryControl.markAsDirty();
this.autoFilledFields.add('countryId');
}
}

form.markAsDirty();
}

/**
* Toggles address activity.
* @param {any} address Client Address
Expand Down
190 changes: 190 additions & 0 deletions src/app/shared/constants/country-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* 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/.
*/

/**
* Mapping of common country names (lowercase) to ISO 3166-1 alpha-2 codes.
* Used by PostalCodeLookupService to resolve country names from Fineract
* code values into API-compatible country codes.
*
* Covers countries commonly served by microfinance institutions.
*/
export const COUNTRY_NAME_TO_ISO_CODE: Record<string, string> = {
// Americas
'united states': 'us',
usa: 'us',
us: 'us',
canada: 'ca',
ca: 'ca',
mexico: 'mx',
méxico: 'mx',
mx: 'mx',
brazil: 'br',
brasil: 'br',
br: 'br',
argentina: 'ar',
ar: 'ar',
colombia: 'co',
co: 'co',
peru: 'pe',
perú: 'pe',
pe: 'pe',
chile: 'cl',
cl: 'cl',
ecuador: 'ec',
ec: 'ec',
guatemala: 'gt',
gt: 'gt',
'dominican republic': 'do',
do: 'do',
honduras: 'hn',
hn: 'hn',
'el salvador': 'sv',
sv: 'sv',
bolivia: 'bo',
bo: 'bo',
paraguay: 'py',
py: 'py',

// Europe
'united kingdom': 'gb',
'great britain': 'gb',
uk: 'gb',
gb: 'gb',
germany: 'de',
deutschland: 'de',
de: 'de',
france: 'fr',
fr: 'fr',
spain: 'es',
españa: 'es',
es: 'es',
italy: 'it',
italia: 'it',
it: 'it',
poland: 'pl',
pl: 'pl',
netherlands: 'nl',
nl: 'nl',
portugal: 'pt',
pt: 'pt',
belgium: 'be',
be: 'be',
switzerland: 'ch',
ch: 'ch',
austria: 'at',
at: 'at',
romania: 'ro',
ro: 'ro',

// Africa
'south africa': 'za',
za: 'za',
nigeria: 'ng',
ng: 'ng',
kenya: 'ke',
ke: 'ke',
tanzania: 'tz',
tz: 'tz',
uganda: 'ug',
ug: 'ug',
ghana: 'gh',
gh: 'gh',
ethiopia: 'et',
et: 'et',
mozambique: 'mz',
mz: 'mz',
rwanda: 'rw',
rw: 'rw',
senegal: 'sn',
sn: 'sn',
mali: 'ml',
ml: 'ml',
cameroon: 'cm',
cm: 'cm',
madagascar: 'mg',
mg: 'mg',
zambia: 'zm',
zm: 'zm',
zimbabwe: 'zw',
zw: 'zw',

// Asia & Pacific
india: 'in',
in: 'in',
pakistan: 'pk',
pk: 'pk',
bangladesh: 'bd',
bd: 'bd',
indonesia: 'id',
id: 'id',
philippines: 'ph',
ph: 'ph',
thailand: 'th',
th: 'th',
japan: 'jp',
jp: 'jp',
australia: 'au',
au: 'au',
myanmar: 'mm',
mm: 'mm',
cambodia: 'kh',
kh: 'kh',
nepal: 'np',
np: 'np',
'sri lanka': 'lk',
lk: 'lk'
};

/**
* Default ISO country codes to try when no country is selected in the form.
* Ordered by prevalence in typical Mifos deployments (microfinance hotspots).
*/
export const DEFAULT_LOOKUP_COUNTRY_CODES = [
'us',
'mx',
'in',
'ke',
'ph',
'br',
'gb'
];

/**
* Aliases for country names that differ between the Zippopotam API
* and common Fineract code value configurations.
* Key: API-returned name (lowercase) → Value: array of alternative names to try matching.
*/
export const COUNTRY_NAME_ALIASES: Record<string, string[]> = {
'great britain': [
'united kingdom',
'uk',
'england',
'britain'
],
'united kingdom': [
'great britain',
'uk',
'england',
'britain'
],
'united states': [
'usa',
'us',
'united states of america'
],
brasil: ['brazil'],
brazil: ['brasil'],
méxico: ['mexico'],
mexico: ['méxico'],
deutschland: ['germany'],
germany: ['deutschland'],
españa: ['spain'],
spain: ['españa'],
italia: ['italy'],
italy: ['italia']
};
Loading
Loading