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
@@ -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 & Aadhaar confirmed against NSDL & 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 — 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 — 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">
|
||||
|
||||
@@ -869,6 +869,49 @@
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Read-only Yes/No flag — replaces the disabled mat-checkbox in view mode
|
||||
so we don't fight Material's greyed-out disabled checkbox styling. */
|
||||
.ro-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&--yes {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--no {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Link select inside status box */
|
||||
.link-select {
|
||||
font-size: 12px;
|
||||
@@ -1402,12 +1445,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-selected chip in read-only mode — visible but visibly unclickable.
|
||||
The selected chip stays fully highlighted (no `--locked` class on it). */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
|
||||
.kyc-doc-chip__icon {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* When the whole wrap is locked, prevent the selected chip's cursor from
|
||||
suggesting interactivity either. */
|
||||
.kyc-doc-chip-wrap--locked .kyc-doc-chip--sel {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.kyc-doc-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1640,4 +1707,524 @@
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Locked variant — used when an existing saved doc is shown but the
|
||||
section's top-level Edit hasn't been clicked yet. */
|
||||
&--locked {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
/* Existing saved document — 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: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
transition: all 0.12s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
border-color: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&__sep {
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__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 form is .disable()'d (view mode after KYC is locked),
|
||||
Material drops opacity and greys everything. That reads as
|
||||
"blurry" to users. Restore proper contrast: dark text, crisp
|
||||
outline, normal-weight label — just NOT editable.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
:host ::ng-deep {
|
||||
/* Outlined form fields in disabled state */
|
||||
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
|
||||
opacity: 1;
|
||||
|
||||
/* Input text */
|
||||
.mat-mdc-input-element,
|
||||
input.mat-mdc-input-element {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important; /* Chrome/Safari override */
|
||||
font-weight: 600;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
/* Floating label */
|
||||
.mat-mdc-floating-label,
|
||||
.mdc-floating-label {
|
||||
color: #64748b !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* The outline lines */
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
/* matPrefix icons (the credit-card / fingerprint glyphs) */
|
||||
.field-prefix-icon mat-icon,
|
||||
.mat-mdc-form-field-icon-prefix mat-icon {
|
||||
color: #475569 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* matSuffix verify-ok icon — keep its green */
|
||||
.verify-icon-ok {
|
||||
color: #059669 !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hint text */
|
||||
.mat-mdc-form-field-hint {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled checkbox — keep the check + label crisp */
|
||||
.mat-mdc-checkbox-disabled {
|
||||
opacity: 1 !important;
|
||||
|
||||
.mdc-checkbox__background {
|
||||
background-color: #059669 !important;
|
||||
border-color: #059669 !important;
|
||||
}
|
||||
|
||||
.mdc-checkbox__checkmark {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.mdc-form-field > label,
|
||||
.mat-mdc-checkbox-label,
|
||||
.mdc-label {
|
||||
color: #0f172a !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled mat-select — same treatment as inputs */
|
||||
.mat-mdc-form-field-disabled .mat-mdc-select-value-text,
|
||||
.mat-mdc-form-field-disabled .mat-mdc-select-value {
|
||||
color: #0f172a !important;
|
||||
-webkit-text-fill-color: #0f172a !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Disabled "toggle visibility" eye icon button */
|
||||
.mat-mdc-form-field-disabled .toggle-vis-btn {
|
||||
color: #64748b !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Unified Identity Verification Summary (PAN + Aadhaar + Link)
|
||||
─── replaces the legacy pan-result-card / aadhaar-result-card /
|
||||
pan-link-box trio with a single polished panel.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.idv-summary {
|
||||
margin: 4px 0 12px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1fae5;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
&.partial {
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero strip — gradient green when both verified, amber when partial. */
|
||||
.idv-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #047857 0%, #059669 60%, #10b981 100%);
|
||||
|
||||
.idv-summary.partial & {
|
||||
background: linear-gradient(135deg, #b45309 0%, #d97706 60%, #f59e0b 100%);
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
opacity: 0.92;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__count {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 22%);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
|
||||
&-num {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&-of {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Two-column body */
|
||||
.idv-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
background: #ecfdf5;
|
||||
|
||||
.idv-summary.partial & {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-col {
|
||||
padding: 14px 18px;
|
||||
border-right: 1px dashed rgb(5 150 105 / 25%);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.muted {
|
||||
background: rgb(255 255 255 / 50%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #047857;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 800;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
background: rgb(255 255 255 / 70%);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
|
||||
strong {
|
||||
color: #334155;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.idv-chip {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
fa-icon {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
|
||||
&__k {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__v {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&.mono {
|
||||
font-family: ui-monospace, 'Roboto Mono', SFMono-Regular, monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer — PAN ↔ Aadhaar link status */
|
||||
.idv-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
background: #f0fdf4;
|
||||
border-top: 1px solid #bbf7d0;
|
||||
|
||||
&__ic {
|
||||
color: #047857;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.not-linked {
|
||||
background: #fef2f2;
|
||||
border-top-color: #fecaca;
|
||||
|
||||
.idv-link__ic,
|
||||
.idv-link__value {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&.exempt {
|
||||
background: #eff6ff;
|
||||
border-top-color: #bfdbfe;
|
||||
|
||||
.idv-link__ic,
|
||||
.idv-link__value {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive — stack columns on narrow screens */
|
||||
@media (width <= 640px) {
|
||||
.idv-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.idv-col {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed rgb(5 150 105 / 25%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-hero {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.idv-link {
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__hint {
|
||||
width: 100%;
|
||||
padding-left: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.idv-row__k {
|
||||
width: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocMergedFile: File | null = null;
|
||||
kycDocMergedPreview: string | null = null;
|
||||
|
||||
/**
|
||||
* Documents already on the server for the linked client_identifier row (edit mode).
|
||||
* Populated in loadExistingKycIdentifier() so the form can show the user what proof
|
||||
* they uploaded previously instead of presenting an empty file picker.
|
||||
*
|
||||
* Each entry: { id, name, fileName, type, size, previewUrl (base64 data URL for images) }
|
||||
*/
|
||||
existingKycIdentifierId: string | null = null;
|
||||
existingKycDocuments: {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
size: number;
|
||||
previewUrl: string | null;
|
||||
}[] = [];
|
||||
|
||||
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
|
||||
photoSigTouched = false;
|
||||
|
||||
@@ -522,6 +539,36 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved KYC ID-proof document in a new browser tab.
|
||||
*
|
||||
* Same flow as the address-step variant: images use the already-loaded
|
||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
||||
* fresh and rendered via a temporary blob URL.
|
||||
*/
|
||||
viewExistingKycDoc(): void {
|
||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingKycDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
/* silent — UI still shows the saved filename */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshKycDocMerged(): Promise<void> {
|
||||
const proof = this.selectedKycDoc;
|
||||
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
|
||||
@@ -662,6 +709,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Load existing photo & signature
|
||||
this.loadExistingPhoto();
|
||||
this.loadExistingSignature();
|
||||
// Show the proof file the user uploaded previously, by matching the KYC document
|
||||
// name against the client_identifier rows on the server.
|
||||
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
||||
},
|
||||
error: () => {
|
||||
// No existing KYC — that's fine for new clients
|
||||
@@ -670,6 +720,86 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the client_identifier row that backs the KYC proof and any documents attached to
|
||||
* it. Address proof + KYC proof both end up as client_identifier rows (see
|
||||
* create-client.saveKycDocProof + saveAddressProof) — so we filter against `kycDocProofs`
|
||||
* before falling back, otherwise the KYC tab could mistakenly show the address proof's file.
|
||||
*
|
||||
* @param kycDocumentName the document type captured on the KYC record — used to pick
|
||||
* the right identifier when a client has more than one
|
||||
*/
|
||||
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
||||
next: (identifiers: any[]) => {
|
||||
if (!identifiers || identifiers.length === 0) return;
|
||||
const isKycType = (i: any) => {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||
};
|
||||
// Pick by document-type name match first; otherwise the first identifier whose
|
||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||
const match = kycDocumentName
|
||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
: null;
|
||||
const identifier = match || identifiers.find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
// Backfill document type + number on the form in case the KYC record didn't have
|
||||
// them (older clients were created via the identifier flow only).
|
||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||
this.kycForm.patchValue({
|
||||
kycDocumentName: identifier.documentType.name,
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
if (!docs || docs.length === 0) return;
|
||||
this.existingKycDocuments = 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
|
||||
}));
|
||||
// Build a preview for the first image-typed document so the UI can show
|
||||
// exactly what the user uploaded last time.
|
||||
const firstImage = this.existingKycDocuments.find((d) => (d.type || '').toLowerCase().startsWith('image/'));
|
||||
if (firstImage) {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(this.existingKycIdentifierId!, 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);
|
||||
// Mirror onto the merged-preview slot so the existing chip-picker
|
||||
// visualisation lights up without template changes.
|
||||
if (!this.kycDocMergedPreview) {
|
||||
this.kycDocMergedPreview = firstImage.previewUrl;
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch existing client profile photo */
|
||||
private loadExistingPhoto(): void {
|
||||
if (!this.clientId) return;
|
||||
@@ -801,9 +931,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.panVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const pan = this.kycForm.value.panNumber;
|
||||
const pan = (this.kycForm.value.panNumber || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.panVerifyError = 'Enter PAN number first.';
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
// Block API call when the PAN format is invalid — Fineract would accept
|
||||
// the raw value otherwise and persist a junk record.
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.panVerifyError = 'Invalid PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).';
|
||||
this.kycForm.get('panNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('panNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,8 +1032,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
verifyEnterprisePan() {
|
||||
if (!this.clientId) return;
|
||||
const pan = this.kycForm.value.enterprisePan;
|
||||
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
|
||||
const pan = (this.kycForm.value.enterprisePan || '').toUpperCase().trim();
|
||||
if (!pan) {
|
||||
this.showPanErrorAlert('Enter Enterprise PAN number first.');
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/.test(pan)) {
|
||||
this.showPanErrorAlert(
|
||||
'Invalid Enterprise PAN format. Expected pattern: ABCDE1234F (5 letters, 4 digits, 1 letter).'
|
||||
);
|
||||
this.kycForm.get('enterprisePan')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('enterprisePan')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.entPanVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -955,8 +1106,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Verify GSTIN via API */
|
||||
verifyGstin() {
|
||||
if (!this.clientId) return;
|
||||
const gstin = this.kycForm.value.gstinNumber;
|
||||
if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
|
||||
const gstin = (this.kycForm.value.gstinNumber || '').toUpperCase().trim();
|
||||
if (!gstin) {
|
||||
this.showPanErrorAlert('Enter GSTIN number first.');
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(gstin)) {
|
||||
this.showPanErrorAlert('Invalid GSTIN format. Expected pattern: 22AAAAA0000A1Z5 (15 characters).');
|
||||
this.kycForm.get('gstinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('gstinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.gstinVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -1019,8 +1180,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
if (!cin) return;
|
||||
const cin = (this.kycForm.value.cinNumber || '').toUpperCase().trim();
|
||||
if (!cin) {
|
||||
this.showPanErrorAlert('Enter CIN number first.');
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/.test(cin)) {
|
||||
this.showPanErrorAlert('Invalid CIN format. Expected pattern: L17110MH1973PLC019786 (21 characters).');
|
||||
this.kycForm.get('cinNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('cinNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cinVerifying = true;
|
||||
this.cinProgressValue = 0;
|
||||
@@ -1068,9 +1239,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.aadhaarVerifyError = 'Client must be created first. Complete General tab and click Next.';
|
||||
return;
|
||||
}
|
||||
const aadhaar = this.kycForm.value.aadhaarNumber;
|
||||
if (!aadhaar || aadhaar.length !== 12) {
|
||||
this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.';
|
||||
const aadhaar = (this.kycForm.value.aadhaarNumber || '').trim();
|
||||
if (!aadhaar) {
|
||||
this.aadhaarVerifyError = 'Enter Aadhaar number first.';
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
if (!/^\d{12}$/.test(aadhaar)) {
|
||||
this.aadhaarVerifyError = 'Invalid Aadhaar format. Aadhaar must be exactly 12 digits.';
|
||||
this.kycForm.get('aadhaarNumber')?.setErrors({ pattern: true });
|
||||
this.kycForm.get('aadhaarNumber')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1287,6 +1465,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const v = formVal;
|
||||
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
|
||||
// Backend regex on these identifiers is upper-case only; tolerate user-typed lowercase
|
||||
// / whitespace here so the save doesn't 400 on
|
||||
// "validation.msg.CLIENTKYC.panNumber.does.not.match.regexp"
|
||||
// (or the equivalent for gstin / cin / enterprisePan).
|
||||
const norm = (s: any): string | null => {
|
||||
const out = (s || '').toString().toUpperCase().replace(/\s+/g, '');
|
||||
return out.length ? out : null;
|
||||
};
|
||||
const panNorm = norm(v.panNumber);
|
||||
const enterprisePanNorm = norm(v.enterprisePan);
|
||||
const gstinNorm = norm(v.gstinNumber);
|
||||
const cinNorm = norm(v.cinNumber);
|
||||
|
||||
return {
|
||||
locale: 'en',
|
||||
@@ -1294,12 +1484,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycType: 1,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? v.enterprisePan || null : v.hasPan ? v.panNumber || null : null,
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
||||
gstinNumber: v.gstinNumber || null,
|
||||
enterprisePan: v.enterprisePan || null,
|
||||
cinNumber: v.cinNumber || null,
|
||||
gstinNumber: gstinNorm,
|
||||
enterprisePan: enterprisePanNorm,
|
||||
cinNumber: cinNorm,
|
||||
incorporationDate: formatDate(v.incorporationDate),
|
||||
// KYC Document
|
||||
kycDocumentName: v.kycDocumentName || null,
|
||||
|
||||
Reference in New Issue
Block a user