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
@@ -28,9 +28,30 @@
<div class="form-grid">
<!-- ══ INDIVIDUAL KYC ══ -->
@if (!isNonIndividual) {
<div class="form-field checkbox-field full-width">
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
</div>
@if (readOnly) {
<!-- In view mode the mat-checkbox renders as a faded grey square
that reads as "blurry". Show a clean Yes/No pill instead. -->
<div class="form-field checkbox-field full-width">
<div class="ro-flag">
<span class="ro-flag__label">Client has PAN Card</span>
@if (kycForm.get('hasPan')?.value) {
<span class="ro-flag__chip ro-flag__chip--yes">
<mat-icon>check_circle</mat-icon>
Yes
</span>
} @else {
<span class="ro-flag__chip ro-flag__chip--no">
<mat-icon>cancel</mat-icon>
No
</span>
}
</div>
</div>
} @else {
<div class="form-field checkbox-field full-width">
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
</div>
}
<!-- Individual PAN with inline verify -->
@if (kycForm.get('hasPan')?.value) {
@@ -83,40 +104,6 @@
}
</div>
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>PAN Verified Successfully</span>
</div>
<div class="pan-result-body">
<div class="pan-result-row">
<span class="pan-result-label">Full Name (as per PAN)</span>
<span class="pan-result-value name">{{ panVerifyName }}</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
<span
class="pan-result-value"
[class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'"
[class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'"
>
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') {
Linked
} @else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') {
Not Linked
} @else {
—
}
</span>
</div>
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ panVerifiedOn }}</span>
</div>
</div>
</div>
}
}
<!-- Aadhaar with independent verify (Individual only) -->
@@ -251,45 +238,140 @@
<fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}
</div>
}
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
<div class="aadhaar-result-card full-width">
<div class="aadhaar-result-card__header">
<fa-icon icon="check-circle" class="aadhaar-result-card__icon"></fa-icon>
<span>Aadhaar Verified Successfully</span>
</div>
<div class="aadhaar-result-card__body">
<span class="aadhaar-result-card__label">Name as per Aadhaar</span>
<span class="aadhaar-result-card__name">{{ aadhaarVerifyName }}</span>
</div>
</div>
}
<!-- PAN-Aadhaar Seeding Status -->
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
<div class="form-field pan-link-status full-width">
<div
class="pan-link-box"
[class.linked]="panAadhaarSeedingStatus === 'Y'"
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
[class.exempt]="panAadhaarSeedingStatus === 'E'"
>
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
<div class="pan-link-info">
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
<span class="pan-link-value">
@if (panAadhaarSeedingStatus === 'Y') {
Linked (Seeded)
<!-- ─── Unified Identity Verification Summary ─── -->
<!-- One polished card that consolidates PAN result, Aadhaar result, and -->
<!-- the PAN–Aadhaar link status into a single, scannable panel. -->
@if (panVerifyStatus === 'VERIFIED' || aadhaarVerifyStatus === 'VERIFIED') {
<div
class="idv-summary full-width"
[class.both]="panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED'"
[class.partial]="
!(panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') &&
(panVerifyStatus === 'VERIFIED' || aadhaarVerifyStatus === 'VERIFIED')
"
>
<!-- Hero strip -->
<div class="idv-hero">
<div class="idv-hero__ic"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="idv-hero__txt">
<div class="idv-hero__title">
@if (panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') {
Identity Verified
} @else if (panVerifyStatus === 'VERIFIED') {
PAN Verified
} @else {
Aadhaar Verified
}
@if (panAadhaarSeedingStatus === 'N') {
Not Linked
</div>
<div class="idv-hero__sub">
@if (panVerifyStatus === 'VERIFIED' && aadhaarVerifyStatus === 'VERIFIED') {
Both PAN &amp; Aadhaar confirmed against NSDL &amp; UIDAI records.
} @else if (panVerifyStatus === 'VERIFIED') {
PAN confirmed against NSDL records. Aadhaar verification pending.
} @else {
Aadhaar confirmed via UIDAI OTP. PAN verification pending.
}
@if (panAadhaarSeedingStatus === 'E') {
Exempt
}
</div>
</div>
<div class="idv-hero__count">
<span class="idv-hero__count-num">
{{ (panVerifyStatus === 'VERIFIED' ? 1 : 0) + (aadhaarVerifyStatus === 'VERIFIED' ? 1 : 0) }}
</span>
<span class="idv-hero__count-of">/ 2</span>
</div>
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
</div>
<!-- Two-column body: PAN | Aadhaar -->
<div class="idv-body">
<!-- PAN column -->
<div class="idv-col" [class.muted]="panVerifyStatus !== 'VERIFIED'">
<div class="idv-col__head">
<fa-icon icon="id-card" class="idv-col__ic"></fa-icon>
<span class="idv-col__label">PAN</span>
@if (panVerifyStatus === 'VERIFIED') {
<span class="idv-chip ok"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
} @else {
<span class="idv-chip pending">Pending</span>
}
</div>
@if (panVerifyStatus === 'VERIFIED') {
<div class="idv-col__rows">
<div class="idv-row">
<span class="idv-row__k">Name on PAN</span>
<span class="idv-row__v name">{{ panVerifyName }}</span>
</div>
<div class="idv-row">
<span class="idv-row__k">PAN Number</span>
<span class="idv-row__v mono">{{ kycForm.get('panNumber')?.value }}</span>
</div>
<div class="idv-row">
<span class="idv-row__k">Verified On</span>
<span class="idv-row__v">{{ panVerifiedOn }}</span>
</div>
</div>
} @else {
<div class="idv-col__empty">Enter PAN above and click <strong>Verify PAN</strong>.</div>
}
</div>
<!-- Aadhaar column -->
<div class="idv-col" [class.muted]="aadhaarVerifyStatus !== 'VERIFIED'">
<div class="idv-col__head">
<fa-icon icon="fingerprint" class="idv-col__ic"></fa-icon>
<span class="idv-col__label">Aadhaar</span>
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="idv-chip ok"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
} @else {
<span class="idv-chip pending">Pending</span>
}
</div>
@if (aadhaarVerifyStatus === 'VERIFIED') {
<div class="idv-col__rows">
<div class="idv-row">
<span class="idv-row__k">Name on Aadhaar</span>
<span class="idv-row__v name">{{ aadhaarVerifyName }}</span>
</div>
<div class="idv-row">
<span class="idv-row__k">Aadhaar Number</span>
<span class="idv-row__v mono"
>XXXX XXXX {{ (kycForm.get('aadhaarNumber')?.value || '').slice(-4) }}</span
>
</div>
<div class="idv-row">
<span class="idv-row__k">Channel</span>
<span class="idv-row__v">UIDAI OTP</span>
</div>
</div>
} @else {
<div class="idv-col__empty">Enter Aadhaar above and click <strong>Verify Aadhaar</strong>.</div>
}
</div>
</div>
<!-- PAN ↔ Aadhaar link footer -->
@if (panAadhaarSeedingStatus) {
<div
class="idv-link"
[class.linked]="panAadhaarSeedingStatus === 'Y'"
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
[class.exempt]="panAadhaarSeedingStatus === 'E'"
>
<fa-icon icon="link" class="idv-link__ic"></fa-icon>
<div class="idv-link__txt">
<span class="idv-link__label">PAN &mdash; Aadhaar Link</span>
<span class="idv-link__value">
@if (panAadhaarSeedingStatus === 'Y') {
Linked (Seeded)
} @else if (panAadhaarSeedingStatus === 'N') {
Not Linked
} @else if (panAadhaarSeedingStatus === 'E') {
Exempt
}
</span>
</div>
<span class="idv-link__hint">Income Tax Portal &mdash; Section 139AA</span>
</div>
}
</div>
}
}
@@ -652,7 +734,7 @@
<div class="kyc-doc-field">
<label class="kyc-doc-label"><mat-icon>description</mat-icon> Document type</label>
<div class="kyc-doc-chip-wrap">
<div class="kyc-doc-chip-wrap" [class.kyc-doc-chip-wrap--locked]="readOnly">
@for (group of kycDocGroups; track group.label; let last = $last) {
<div class="kyc-doc-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="kyc-doc-chip-grid">
@@ -660,9 +742,11 @@
<span
class="kyc-doc-chip"
[class.kyc-doc-chip--sel]="isKycDocSelected(p)"
(click)="selectKycDoc(p)"
(keydown.enter)="selectKycDoc(p)"
tabindex="0"
[class.kyc-doc-chip--locked]="readOnly && !isKycDocSelected(p)"
[attr.aria-disabled]="readOnly ? 'true' : null"
(click)="readOnly ? null : selectKycDoc(p)"
(keydown.enter)="readOnly ? null : selectKycDoc(p)"
[attr.tabindex]="readOnly ? -1 : 0"
role="button"
>
<mat-icon class="kyc-doc-chip__icon">
@@ -699,59 +783,78 @@
</span>
<span class="kyc-doc-upload-req">Required</span>
</div>
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('front')"
>
<mat-icon>close</mat-icon>
</button>
<!-- Existing saved ID-proof doc — locked by default. Replace flow
unlocks automatically when the section-level Edit button toggles
`readOnly` off. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedKycDoc.sides === 2) {
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="kyc-doc-upload-req">Required</span>
</div>
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back"
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('back')"
(click)="$event.preventDefault(); removeKycDocFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="kyc-doc-dropzone__sep">·</span>
<span class="kyc-doc-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
@@ -759,11 +862,104 @@
</div>
}
</label>
}
</div>
@if (selectedKycDoc.sides === 2) {
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- Hide Required pill when saved combined doc covers both sides -->
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
<span class="kyc-doc-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
</span>
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
</div>
} @else {
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingKycDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — view + replace. -->
<div class="kyc-doc-dropzone__existing">
@if (kycDocMergedPreview) {
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
} @else {
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
}
<div class="kyc-doc-dropzone__existing-meta">
<span class="kyc-doc-dropzone__existing-name">
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
</span>
<div class="kyc-doc-dropzone__existing-actions">
<button
type="button"
class="kyc-doc-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="kyc-doc-dropzone__sep">·</span>
<span class="kyc-doc-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
}
</div>
}
</div>
@if (selectedKycDoc.sides === 2 && kycDocMergedPreview) {
<!-- Combined preview + re-crop is an edit-mode tool — hide in read-only,
and don't duplicate the thumbnail when nothing's been picked yet. -->
@if (
selectedKycDoc.sides === 2 && kycDocMergedPreview && !readOnly && (kycDocFiles.front || kycDocFiles.back)
) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">