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
@@ -123,7 +123,7 @@
<div class="addr-proof-field">
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
<div class="addr-chip-wrap">
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="addr-chip-grid">
@@ -131,9 +131,11 @@
<span
class="addr-chip"
[class.addr-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
[attr.aria-disabled]="readOnly ? 'true' : null"
(click)="readOnly ? null : selectAddrProof(p)"
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
[attr.tabindex]="readOnly ? -1 : 0"
role="button"
>
<mat-icon class="addr-chip__icon">
@@ -186,59 +188,78 @@
</span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.front"
[class.addr-dropzone--req]="!addrFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
<!-- Existing saved document — locked by default. Replace flow
unlocks automatically when the section's top-level Edit
button toggles `readOnly` off. -->
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="addr-upload-req">Required</span>
</div>
} @else {
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back"
[class.addr-dropzone--req]="!addrFiles.back"
[class.addr-dropzone--has]="addrFiles.front || existingAddressProofDocuments.length > 0"
[class.addr-dropzone--req]="!addrFiles.front && existingAddressProofDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingAddressProofDocuments.length > 0) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
type="button"
class="addr-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="addr-dropzone__sep">·</span>
<span class="addr-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
@@ -246,11 +267,104 @@
</div>
}
</label>
}
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- "Required" pill is hidden when a saved combined doc covers both sides -->
@if (!(existingAddressProofDocuments.length > 0 && !addrFiles.back)) {
<span class="addr-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
</div>
} @else {
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back || existingAddressProofDocuments.length > 0"
[class.addr-dropzone--req]="!addrFiles.back && existingAddressProofDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingAddressProofDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — same view + replace
pattern as the front slot, anchored to the saved file. -->
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
type="button"
class="addr-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="addr-dropzone__sep">·</span>
<span class="addr-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
}
</div>
}
</div>
@if (selectedAddrProof.sides === 2 && addrMergedPreview) {
<!-- Combined preview + re-crop buttons are an edit-mode tool; hide in read-only.
Also hide when the saved combined PNG is already visible as the thumbnail
inside both slot tiles (no value in repeating the same image below). -->
@if (selectedAddrProof.sides === 2 && addrMergedPreview && !readOnly && (addrFiles.front || addrFiles.back)) {
<div class="addr-merged">
<div class="addr-merged__head">
<span class="addr-merged__title">
@@ -333,12 +333,35 @@ $muted: #64748b;
}
}
/* Non-selected chip in read-only mode — visible but visibly unclickable.
The selected chip keeps its full highlight (no `--locked` class). */
&--locked {
cursor: default;
background: #f8fafc;
border-color: #e2e8f0;
color: #94a3b8;
.addr-chip__icon {
color: #cbd5e1;
}
&:hover {
border-color: #e2e8f0;
color: #94a3b8;
}
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
}
/* When the wrap is locked, the selected chip shouldn't show a pointer cursor either. */
.addr-chip-wrap--locked .addr-chip--sel {
cursor: default;
}
.addr-proof-banner {
display: flex;
align-items: center;
@@ -559,4 +582,201 @@ $muted: #64748b;
&__remove {
flex-shrink: 0;
}
/* Locked variant — used when an existing saved doc is shown but the user
hasn't clicked "Edit" yet. Removes the hand-cursor and the green hover. */
&--locked {
cursor: default;
&:hover {
background: #f0fdf4;
border-color: #86efac;
}
}
/* Existing saved document (edit mode) — thumbnail + filename + actions. */
&__existing {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
&__thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 8px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
flex-shrink: 0;
}
&__doc-ic {
width: 40px;
height: 40px;
font-size: 32px;
color: #1e88e5;
flex-shrink: 0;
}
&__existing-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
flex: 1;
}
&__existing-name {
font-size: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__existing-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
&__view,
&__edit {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.12s;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__view {
background: #eff6ff;
color: #1e40af;
border-color: #bfdbfe;
&:hover {
background: #dbeafe;
border-color: #1e40af;
}
}
&__edit {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
&:hover {
background: #fde68a;
border-color: #92400e;
}
}
&__existing-hint {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #1e40af;
font-weight: 600;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
/* ═══════════════════════════════════════════════════════════════
Read-only / disabled form-field overrides
─── When the stepper is in view mode the form is .disable()'d.
Material drops opacity to ~0.38 which reads as "blurry". Restore
proper contrast so disabled values are still clearly legible.
═══════════════════════════════════════════════════════════════ */
:host ::ng-deep {
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
opacity: 1;
.mat-mdc-input-element,
input.mat-mdc-input-element,
textarea.mat-mdc-input-element {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
font-weight: 600;
caret-color: transparent;
}
.mat-mdc-floating-label,
.mdc-floating-label {
color: #64748b !important;
opacity: 1 !important;
}
.mdc-notched-outline__leading,
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: #cbd5e1 !important;
border-width: 1px !important;
}
.field-prefix-icon mat-icon,
.mat-mdc-form-field-icon-prefix mat-icon,
.mat-mdc-form-field-icon-suffix mat-icon {
color: #475569 !important;
opacity: 1;
}
.mat-mdc-form-field-hint {
color: #64748b !important;
opacity: 1;
}
.mat-mdc-select-value-text,
.mat-mdc-select-value {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
font-weight: 600;
}
.mat-mdc-select-arrow {
color: #94a3b8 !important;
}
}
/* Disabled checkbox — keep visuals crisp */
.mat-mdc-checkbox-disabled {
opacity: 1 !important;
.mdc-form-field > label,
.mat-mdc-checkbox-label,
.mdc-label {
color: #0f172a !important;
opacity: 1 !important;
font-weight: 500;
}
.mdc-checkbox__background {
background-color: #059669 !important;
border-color: #059669 !important;
}
.mdc-checkbox__checkmark {
color: #fff !important;
}
}
}
@@ -183,6 +183,21 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
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',
@@ -386,7 +401,18 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
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;
@@ -394,6 +420,95 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
/**
* 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, '') || '';
@@ -451,12 +566,121 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
/**
* 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':