Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves

Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
@@ -104,14 +104,23 @@
<div class="addr-field__value">{{ address.countyDistrict }}</div>
</div>
}
@if (address.stateProvinceId) {
<div class="addr-field">
<div class="addr-field__label">State</div>
<div class="addr-field__value">
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
</div>
<!-- State — always render. The address payload may carry the value
under `stateProvinceId` (numeric → resolve via template options),
`stateName` / `stateProvinceName` (string), or be missing
entirely. Render whatever we can find, otherwise show "—". -->
<div class="addr-field">
<div class="addr-field__label">State</div>
<div class="addr-field__value">
{{
(address.stateProvinceId
? getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name
: null) ||
address.stateName ||
address.stateProvinceName ||
'—'
}}
</div>
}
</div>
<div class="addr-field">
<div class="addr-field__label">Country</div>
<div class="addr-field__value">
@@ -16,6 +16,9 @@ import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
/** Custom Components */
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { EditAddressDialogComponent } from './edit-address-dialog/edit-address-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
/** Custom Services */
import { TranslateService } from '@ngx-translate/core';
@@ -87,29 +90,42 @@ export class AddressTabComponent {
* Adds a client address.
*/
addAddress() {
const data = {
title:
this.translateService.instant('labels.buttons.Add') +
' ' +
this.translateService.instant('labels.catalogs.Client') +
' ' +
this.translateService.instant('labels.heading.Address'),
formfields: this.getAddressFormFields('add'),
layout: { columns: 2, columnWidth: 350 }
};
const addAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
addAddressDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
this.clientService
.createClientAddress(this.clientId, response.data.value.addressType, response.data.value)
.subscribe((res: any) => {
const addressData = response.data.value;
addressData.addressId = res.resourceId;
addressData.addressType = this.getSelectedValue('addressTypeIdOptions', addressData.addressType).name;
addressData.isActive = false;
this.clientAddressData.push(addressData);
});
}
// Use the Nexora dialog with PIN lookup + "Copy from existing address" picker.
const ref = this.dialog.open(EditAddressDialogComponent, {
data: {
mode: 'add',
title:
this.translateService.instant('labels.buttons.Add') +
' ' +
this.translateService.instant('labels.catalogs.Client') +
' ' +
this.translateService.instant('labels.heading.Address'),
address: null,
template: this.clientAddressTemplate,
fieldConfig: this.clientAddressFieldConfig,
existingAddresses: this.clientAddressData || []
},
panelClass: 'ead-panel',
autoFocus: false
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.value) return;
const payload = { ...response.value };
const typeName = this.getSelectedValue('addressTypeIdOptions', payload.addressType)?.name;
this.clientService.createClientAddress(this.clientId, payload.addressType, payload).subscribe({
next: (res: any) => {
payload.addressId = res.resourceId;
payload.addressType = typeName;
payload.isActive = false;
this.clientAddressData.push(payload);
this.showSuccess('Address Added', `${typeName || 'Address'} has been added to this client's profile.`);
},
error: (err: any) => {
const apiMsg =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
this.showError(`Failed to add address. ${apiMsg}`);
}
});
});
}
@@ -119,31 +135,54 @@ export class AddressTabComponent {
* @param {number} index address index
*/
editAddress(address: any, index: number) {
const data = {
title:
this.translateService.instant('labels.buttons.Edit') +
' ' +
this.translateService.instant('labels.catalogs.Client') +
' ' +
this.translateService.instant('labels.heading.Address'),
formfields: this.getAddressFormFields('edit', address),
layout: { columns: 2, columnWidth: 350, addButtonText: 'Edit' }
};
const editAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
editAddressDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
const addressData = response.data.value;
addressData.addressId = address.addressId;
addressData.isActive = address.isActive;
this.clientService
.editClientAddress(this.clientId, address.addressTypeId, addressData)
.subscribe((res: any) => {
addressData.addressTypeId = address.addressTypeId;
addressData.addressType = address.addressType;
this.clientAddressData[index] = addressData;
});
}
// Open the Nexora edit dialog with PIN lookup (mirrors the stepper).
const ref = this.dialog.open(EditAddressDialogComponent, {
data: {
title:
this.translateService.instant('labels.buttons.Edit') +
' ' +
this.translateService.instant('labels.catalogs.Client') +
' ' +
this.translateService.instant('labels.heading.Address'),
address,
template: this.clientAddressTemplate,
fieldConfig: this.clientAddressFieldConfig
},
panelClass: 'ead-panel',
autoFocus: false
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.value) return;
const addressData = { ...response.value };
addressData.addressId = address.addressId;
addressData.isActive = address.isActive;
this.clientService.editClientAddress(this.clientId, address.addressTypeId, addressData).subscribe({
next: () => {
addressData.addressTypeId = address.addressTypeId;
addressData.addressType = address.addressType;
this.clientAddressData[index] = addressData;
this.showSuccess('Address Updated', `${address.addressType || 'Address'} has been updated successfully.`);
},
error: (err: any) => {
const apiMsg =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
this.showError(`Failed to update address. ${apiMsg}`);
}
});
});
}
/** Polished success modal — matches the rest of the Nexora UI. */
private showSuccess(title: string, message: string): void {
this.dialog.open(SuccessDialogComponent, {
data: { title, message, buttonLabel: 'OK' },
panelClass: 'sd-panel'
});
}
/** Polished error modal — single string body. */
private showError(message: string): void {
this.dialog.open(ErrorDialogComponent, { data: message });
}
/**
@@ -297,7 +336,9 @@ export class AddressTabComponent {
this.isFieldEnabled('countyDistrict')
? new InputBase({
controlName: 'countyDistrict',
label: this.translateService.instant('labels.inputs.State / Province'),
// Was mistakenly labeled "State / Province" — that label belongs to
// the stateProvinceId select above. This control holds the District.
label: this.translateService.instant('labels.inputs.County / District'),
value: address ? address.countyDistrict : '',
type: 'text',
order: 11
@@ -0,0 +1,186 @@
<!--
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/.
-->
<div class="ead-root">
<div class="ead-head">
<div class="ead-head__left">
<fa-icon icon="map-marker-alt" class="ead-head__ic"></fa-icon>
<div>
<h2 class="ead-head__title">{{ data.title || (isAdd ? 'Add Client Address' : 'Edit Client Address') }}</h2>
<div class="ead-head__sub">
@if (isAdd) {
Select address type and enter PIN to auto-fill city, district &amp; state.
} @else {
Update postal details. Enter PIN to auto-fill city, district &amp; state.
}
</div>
</div>
</div>
<button class="ead-x" (click)="cancel()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<form class="ead-body" [formGroup]="form" (ngSubmit)="submit()">
<!-- Address Type + Copy-from (Add mode only) -->
@if (isAdd) {
<div class="ead-grid">
<div class="ead-field">
<label>Address Type *</label>
<select formControlName="addressType">
<option [ngValue]="''">— Select address type —</option>
@for (t of addressTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
@if (form.get('addressType')?.touched && form.get('addressType')?.hasError('required')) {
<div class="ead-err">Address type is required.</div>
}
</div>
@if (copySourceOptions.length > 0) {
<div class="ead-field">
<label>Copy from existing address (optional)</label>
<select
[(ngModel)]="copyFromAddressId"
[ngModelOptions]="{ standalone: true }"
(change)="onCopyFromAddress()"
>
<option [ngValue]="''">— Don't copy —</option>
@for (a of copySourceOptions; track a.addressId) {
<option [ngValue]="a.addressId">
{{ a.addressType || 'Address' }} &middot; {{ a.city || a.countyDistrict || '' }}
@if (a.postalCode) {
&middot; {{ a.postalCode }}
}
</option>
}
</select>
</div>
}
</div>
}
<!-- PIN + Street -->
<div class="ead-grid">
<div class="ead-field">
<label>Postal Code *</label>
<div class="ead-input-wrap">
<fa-icon icon="map-pin" class="ead-input-ic"></fa-icon>
<input
type="text"
inputmode="numeric"
maxlength="6"
formControlName="postalCode"
placeholder="6-digit PIN"
(input)="onPinInput()"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (pinStatus.loading) {
<span class="ead-pin-status loading"
><fa-icon icon="spinner" class="fa-spin"></fa-icon> Looking up&hellip;</span
>
} @else if (pinStatus.found) {
<span class="ead-pin-status ok"><fa-icon icon="check-circle"></fa-icon> PIN verified</span>
} @else if (pinStatus.error) {
<span class="ead-pin-status err"
><fa-icon icon="exclamation-triangle"></fa-icon> {{ pinStatus.message }}</span
>
}
</div>
@if (form.get('postalCode')?.touched && form.get('postalCode')?.hasError('pattern')) {
<div class="ead-err">Enter a valid 6-digit PIN.</div>
}
</div>
@if (isFieldEnabled('street')) {
<div class="ead-field">
<label>Street</label>
<input type="text" formControlName="street" placeholder="Street, Road, Lane" />
</div>
}
</div>
<!-- Address lines -->
<div class="ead-grid">
@if (isFieldEnabled('addressLine1')) {
<div class="ead-field">
<label>Address Line 1</label>
<input type="text" formControlName="addressLine1" placeholder="House / Building No." />
</div>
}
@if (isFieldEnabled('addressLine2')) {
<div class="ead-field">
<label>Address Line 2</label>
<input type="text" formControlName="addressLine2" placeholder="Apartment / Block" />
</div>
}
</div>
<div class="ead-grid">
@if (isFieldEnabled('addressLine3')) {
<div class="ead-field">
<label>Address Line 3</label>
<input type="text" formControlName="addressLine3" placeholder="Locality / Sector" />
</div>
}
@if (isFieldEnabled('townVillage')) {
<div class="ead-field">
<label>Town / Village (Locality)</label>
<select formControlName="townVillage">
<option [ngValue]="''">— Select —</option>
@for (loc of effectiveLocalityOptions; track loc) {
<option [ngValue]="loc">{{ loc }}</option>
}
</select>
</div>
}
</div>
<!-- City / District / State -->
<div class="ead-grid ead-grid--three">
@if (isFieldEnabled('city')) {
<div class="ead-field">
<label>City</label>
<input type="text" formControlName="city" placeholder="City" />
</div>
}
@if (isFieldEnabled('countyDistrict')) {
<div class="ead-field">
<label>County / District</label>
<input type="text" formControlName="countyDistrict" placeholder="District" />
</div>
}
@if (isFieldEnabled('stateProvinceId')) {
<div class="ead-field">
<label>State / Province</label>
<!-- Plain text — populated by the PIN service, mirrors create wizard.
On submit we resolve to the matching stateProvinceId for the API. -->
<input type="text" formControlName="state" placeholder="State / Province" />
</div>
}
</div>
<!-- Country -->
@if (isFieldEnabled('countryId')) {
<div class="ead-grid">
<div class="ead-field">
<label>Country</label>
<input type="text" formControlName="country" placeholder="Country" />
</div>
<div></div>
</div>
}
<div class="ead-actions">
<button type="button" class="ead-btn ghost" (click)="cancel()">Cancel</button>
<button type="submit" class="ead-btn primary" [disabled]="form.invalid">
<fa-icon [icon]="isAdd ? 'plus' : 'check'"></fa-icon>
{{ isAdd ? 'Add Address' : 'Save Changes' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,274 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$green: #10b981;
$red: #ef4444;
$amber: #f59e0b;
$purple: #7c3aed;
:host {
display: block;
}
.ead-root {
background: #fff;
width: 760px;
max-width: 96vw;
max-height: 92vh;
display: flex;
flex-direction: column;
}
.ead-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__ic {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, $blue 0%, $purple 100%);
color: #fff;
font-size: 14px;
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: $navy;
}
&__sub {
margin-top: 2px;
font-size: 11.5px;
color: #64748b;
}
}
.ead-x {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background: #fef2f2;
color: $red;
border-color: #fecaca;
}
}
.ead-body {
flex: 1;
overflow-y: auto;
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ead-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
&--three {
grid-template-columns: 1fr 1fr 1fr;
}
}
.ead-field {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 11px;
font-weight: 600;
color: #475569;
}
input,
select {
height: 38px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: $navy;
font-size: 13px;
font-weight: 500;
transition: all 0.12s;
&:focus {
outline: none;
border-color: $blue;
box-shadow: 0 0 0 3px rgb(30 136 229 / 14%);
}
}
select {
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #64748b 50%), linear-gradient(135deg, #64748b 50%, transparent 50%);
background-position:
calc(100% - 14px) 50%,
calc(100% - 9px) 50%;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
padding-right: 28px;
}
}
.ead-input-wrap {
position: relative;
display: flex;
align-items: center;
input {
flex: 1;
padding-left: 36px;
}
}
.ead-input-ic {
position: absolute;
left: 12px;
color: $blue;
font-size: 13px;
pointer-events: none;
}
.ead-pin-status {
position: absolute;
right: 10px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
fa-icon {
font-size: 11px;
}
&.ok {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
&.loading {
background: #eff6ff;
color: $blue-d;
border: 1px solid #bfdbfe;
}
&.err {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
}
.ead-err {
font-size: 10.5px;
color: $red;
font-weight: 600;
}
.ead-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid #e2e8f0;
padding-top: 14px;
margin-top: 4px;
}
.ead-btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
fa-icon {
font-size: 11px;
}
&.ghost {
background: #fff;
border-color: #d0d5dd;
color: #475569;
&:hover {
background: #f8fafc;
border-color: #94a3b8;
color: $navy;
}
}
&.primary {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
border-color: $blue-d;
color: #fff;
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
&:hover:not(:disabled) {
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
transform: translateY(-1px);
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
@media (width <= 640px) {
.ead-grid,
.ead-grid--three {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,354 @@
/**
* 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<EditAddressDialogComponent>);
public data = inject<EditAddressDialogData>(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<string, PinPostOffice[]> = {};
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<string, any> = {
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<any[]>(`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<string>();
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<string, any> = {};
// 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<string, any> = {
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();
}
}