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>
355 lines
13 KiB
TypeScript
355 lines
13 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, 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();
|
|
}
|
|
}
|