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:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
+157
-43
@@ -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">
|
||||
|
||||
+220
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user