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
@@ -0,0 +1,186 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Shared mixin for the client stepper steps.
*
* When the per-step `readOnly` flag is on, the host calls `form.disable()` which
* cascades Material's default `~0.38 opacity` onto every outlined input,
* select, checkbox and label. Visually it reads as "blurry". This mixin keeps
* the disabled affordances (no caret, no border highlight on focus, no
* pointer cursor on selects) but restores crisp colour + weight so the saved
* values stay legible.
*
* Usage in a step component's .scss:
*
* @use '../stepper-readonly-overrides' as readonly;
*
* :host {
* @include readonly.readonly-form-overrides;
* }
*/
@mixin readonly-form-overrides {
/* ─── MDC design-token overrides ───
These CSS custom properties feed Material's disabled-state colors at the
token level — higher in the cascade than any rule selector — so they win
even against Material's own !important declarations baked into MDC styles. */
--mdc-outlined-text-field-disabled-input-text-color: #0f172a;
--mdc-outlined-text-field-disabled-label-text-color: #64748b;
--mdc-outlined-text-field-disabled-outline-color: #cbd5e1;
--mdc-outlined-text-field-disabled-supporting-text-color: #64748b;
--mdc-outlined-text-field-disabled-leading-icon-color: #475569;
--mdc-outlined-text-field-disabled-trailing-icon-color: #475569;
--mdc-filled-text-field-disabled-input-text-color: #0f172a;
--mdc-filled-text-field-disabled-label-text-color: #64748b;
--mat-select-disabled-trigger-text-color: #0f172a;
--mat-form-field-disabled-input-text-color: #0f172a;
--mat-form-field-disabled-input-text-placeholder-color: #64748b;
--mdc-checkbox-disabled-selected-icon-color: #059669;
--mdc-checkbox-disabled-unselected-icon-color: #94a3b8;
::ng-deep {
/* Outlined form fields in disabled state */
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
opacity: 1;
/* Input value */
.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;
}
/* Floating label */
.mat-mdc-floating-label,
.mdc-floating-label {
color: #64748b !important;
opacity: 1 !important;
}
/* Outline border */
.mdc-notched-outline__leading,
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: #cbd5e1 !important;
border-width: 1px !important;
}
/* matPrefix / matSuffix icons */
.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;
}
/* Hint text */
.mat-mdc-form-field-hint {
color: #64748b !important;
opacity: 1;
}
/* mat-select value + arrow.
MDC nests the visible text deeper than `.mat-mdc-select-value` — the
actual `<span>` that renders the chosen option is `.mat-mdc-select-min-line`.
Without explicitly forcing it, MDC's disabled-state CSS wins and the
text reads as washed-out grey. */
.mat-mdc-select-value-text,
.mat-mdc-select-value,
.mat-mdc-select-min-line,
.mat-mdc-select-placeholder {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
font-weight: 600;
opacity: 1 !important;
}
.mat-mdc-select-arrow {
color: #94a3b8 !important;
}
/* The disabled select wrapper itself drops opacity in MDC — undo that. */
.mat-mdc-select-disabled,
.mat-mdc-select-disabled .mat-mdc-select-trigger {
opacity: 1 !important;
}
/* The MDC text-field root (parent of input + outline) also gets a
disabled modifier. Strip any opacity it applies. */
.mdc-text-field--disabled {
opacity: 1 !important;
.mdc-text-field__input,
input,
textarea {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
opacity: 1 !important;
}
}
/* Native suffix icon buttons (e.g. eye-toggle) */
.toggle-vis-btn,
.mat-mdc-icon-button {
color: #64748b !important;
opacity: 1;
}
}
/* Disabled checkboxes — keep the check + label 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;
}
}
/* Disabled mat-radio */
.mat-mdc-radio-button.mat-mdc-radio-disabled {
opacity: 1 !important;
.mdc-form-field > label,
.mdc-label {
color: #0f172a !important;
opacity: 1 !important;
font-weight: 500;
}
}
/* Disabled mat-slide-toggle */
.mat-mdc-slide-toggle.mat-mdc-slide-toggle-disabled {
opacity: 1 !important;
}
/* Native UA override — Chrome/Safari force their own grey on disabled
form controls via `-webkit-text-fill-color`, which beats `color`. */
input:disabled,
select:disabled,
textarea:disabled {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
opacity: 1 !important;
}
}
}
@@ -105,3 +105,59 @@
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════════
Read-only / disabled form-field overrides
─── Mirrors the India-address step so view-mode addresses stay
legible instead of looking ghosted at 0.38 opacity.
═══════════════════════════════════════════════════════════════ */
: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;
}
}
}
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../stepper-readonly-overrides' as readonly;
:host {
@include readonly.readonly-form-overrides;
}
.date-picker {
width: 92%;
}
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../stepper-readonly-overrides' as readonly;
:host {
@include readonly.readonly-form-overrides;
}
/**
* Entity Classification — Scheme & Sector Classification
*/
@@ -259,7 +259,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// non-individuals (companies / trusts) have no person-to-person relationship to model here.
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
// Filter the host client out of results so they can't even appear as
// a candidate. The dialog also blocks selection defensively in case
// the host slips through (e.g. via a paged search).
this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
@@ -278,6 +281,17 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
*/
linkedSelectError = '';
/**
* Self-check: prevent a client from being added as their own family member.
* The host client's id is passed in via dialog data — comparing as strings
* because the search API uses numbers but route params are strings.
*/
isSelfClient(clientId: number | string): boolean {
const host = this.data?.hostClientId;
if (host == null || clientId == null) return false;
return String(host) === String(clientId);
}
/**
* True if the given client id is already linked as a family member of
* this client. The member currently being edited is excluded so its own
@@ -298,6 +312,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
/** Select a client from search results and fetch full details */
selectClient(client: any): void {
if (this.isSelfClient(client?.id)) {
this.linkedSelectError = `You can't add this client to their own family. Please pick a different person.`;
return;
}
if (this.isClientAlreadyLinked(client?.id)) {
this.linkedSelectError = `${client?.displayName || 'This client'} is already linked as a family member.`;
return;
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../stepper-readonly-overrides' as readonly;
:host {
@include readonly.readonly-form-overrides;
}
.zoomable {
cursor: zoom-in;
transition:
@@ -135,7 +135,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
data: {
context: 'Add',
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
@@ -154,7 +155,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
context: 'View',
member,
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
@@ -168,7 +170,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
context: 'Edit',
member,
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../stepper-readonly-overrides' as readonly;
:host {
@include readonly.readonly-form-overrides;
}
form {
padding: 0;
}
@@ -123,7 +123,7 @@
<div class="addr-proof-field">
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
<div class="addr-chip-wrap">
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="addr-chip-grid">
@@ -131,9 +131,11 @@
<span
class="addr-chip"
[class.addr-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
[attr.aria-disabled]="readOnly ? 'true' : null"
(click)="readOnly ? null : selectAddrProof(p)"
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
[attr.tabindex]="readOnly ? -1 : 0"
role="button"
>
<mat-icon class="addr-chip__icon">
@@ -186,59 +188,78 @@
</span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.front"
[class.addr-dropzone--req]="!addrFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
<!-- Existing saved document — locked by default. Replace flow
unlocks automatically when the section's top-level Edit
button toggles `readOnly` off. -->
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="addr-upload-req">Required</span>
</div>
} @else {
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back"
[class.addr-dropzone--req]="!addrFiles.back"
[class.addr-dropzone--has]="addrFiles.front || existingAddressProofDocuments.length > 0"
[class.addr-dropzone--req]="!addrFiles.front && existingAddressProofDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingAddressProofDocuments.length > 0) {
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
type="button"
class="addr-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="addr-dropzone__sep">·</span>
<span class="addr-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
@@ -246,11 +267,104 @@
</div>
}
</label>
}
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<!-- "Required" pill is hidden when a saved combined doc covers both sides -->
@if (!(existingAddressProofDocuments.length > 0 && !addrFiles.back)) {
<span class="addr-upload-req">Required</span>
}
</div>
<!-- Locked view: saved combined doc covers the back side too. -->
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
Click here to view
</button>
</div>
</div>
</div>
} @else {
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back || existingAddressProofDocuments.length > 0"
[class.addr-dropzone--req]="!addrFiles.back && existingAddressProofDocuments.length === 0"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else if (existingAddressProofDocuments.length > 0) {
<!-- Edit mode WITH existing combined doc — same view + replace
pattern as the front slot, anchored to the saved file. -->
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
type="button"
class="addr-dropzone__view"
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingAddressProof()"
>
<mat-icon>visibility</mat-icon>
View
</button>
<span class="addr-dropzone__sep">·</span>
<span class="addr-dropzone__existing-hint">
<mat-icon>cloud_upload</mat-icon>
Click here to replace
</span>
</div>
</div>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
}
</div>
}
</div>
@if (selectedAddrProof.sides === 2 && addrMergedPreview) {
<!-- Combined preview + re-crop buttons are an edit-mode tool; hide in read-only.
Also hide when the saved combined PNG is already visible as the thumbnail
inside both slot tiles (no value in repeating the same image below). -->
@if (selectedAddrProof.sides === 2 && addrMergedPreview && !readOnly && (addrFiles.front || addrFiles.back)) {
<div class="addr-merged">
<div class="addr-merged__head">
<span class="addr-merged__title">
@@ -333,12 +333,35 @@ $muted: #64748b;
}
}
/* Non-selected chip in read-only mode — visible but visibly unclickable.
The selected chip keeps its full highlight (no `--locked` class). */
&--locked {
cursor: default;
background: #f8fafc;
border-color: #e2e8f0;
color: #94a3b8;
.addr-chip__icon {
color: #cbd5e1;
}
&:hover {
border-color: #e2e8f0;
color: #94a3b8;
}
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
}
/* When the wrap is locked, the selected chip shouldn't show a pointer cursor either. */
.addr-chip-wrap--locked .addr-chip--sel {
cursor: default;
}
.addr-proof-banner {
display: flex;
align-items: center;
@@ -559,4 +582,201 @@ $muted: #64748b;
&__remove {
flex-shrink: 0;
}
/* Locked variant — used when an existing saved doc is shown but the user
hasn't clicked "Edit" yet. Removes the hand-cursor and the green hover. */
&--locked {
cursor: default;
&:hover {
background: #f0fdf4;
border-color: #86efac;
}
}
/* Existing saved document (edit mode) — thumbnail + filename + actions. */
&__existing {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
&__thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 8px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
flex-shrink: 0;
}
&__doc-ic {
width: 40px;
height: 40px;
font-size: 32px;
color: #1e88e5;
flex-shrink: 0;
}
&__existing-meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
flex: 1;
}
&__existing-name {
font-size: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__existing-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
&__view,
&__edit {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 700;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.12s;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__view {
background: #eff6ff;
color: #1e40af;
border-color: #bfdbfe;
&:hover {
background: #dbeafe;
border-color: #1e40af;
}
}
&__edit {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
&:hover {
background: #fde68a;
border-color: #92400e;
}
}
&__existing-hint {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #1e40af;
font-weight: 600;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
}
/* ═══════════════════════════════════════════════════════════════
Read-only / disabled form-field overrides
─── When the stepper is in view mode the form is .disable()'d.
Material drops opacity to ~0.38 which reads as "blurry". Restore
proper contrast so disabled values are still clearly legible.
═══════════════════════════════════════════════════════════════ */
:host ::ng-deep {
.mat-mdc-form-field.mat-form-field-appearance-outline.mat-form-field-disabled {
opacity: 1;
.mat-mdc-input-element,
input.mat-mdc-input-element,
textarea.mat-mdc-input-element {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
font-weight: 600;
caret-color: transparent;
}
.mat-mdc-floating-label,
.mdc-floating-label {
color: #64748b !important;
opacity: 1 !important;
}
.mdc-notched-outline__leading,
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: #cbd5e1 !important;
border-width: 1px !important;
}
.field-prefix-icon mat-icon,
.mat-mdc-form-field-icon-prefix mat-icon,
.mat-mdc-form-field-icon-suffix mat-icon {
color: #475569 !important;
opacity: 1;
}
.mat-mdc-form-field-hint {
color: #64748b !important;
opacity: 1;
}
.mat-mdc-select-value-text,
.mat-mdc-select-value {
color: #0f172a !important;
-webkit-text-fill-color: #0f172a !important;
font-weight: 600;
}
.mat-mdc-select-arrow {
color: #94a3b8 !important;
}
}
/* Disabled checkbox — keep visuals crisp */
.mat-mdc-checkbox-disabled {
opacity: 1 !important;
.mdc-form-field > label,
.mat-mdc-checkbox-label,
.mdc-label {
color: #0f172a !important;
opacity: 1 !important;
font-weight: 500;
}
.mdc-checkbox__background {
background-color: #059669 !important;
border-color: #059669 !important;
}
.mdc-checkbox__checkmark {
color: #fff !important;
}
}
}
@@ -183,6 +183,21 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
addrMergedFile: File | null = null;
addrMergedPreview: string | null = null;
/**
* Identifier row + attached documents that back the address proof on the server (edit
* mode). Populated by loadExistingAddressProof() so the UI can show the file the user
* uploaded last time instead of an empty picker.
*/
existingAddressProofIdentifierId: string | null = null;
existingAddressProofDocuments: {
id: string;
name: string;
fileName: string;
type: string;
size: number;
previewUrl: string | null;
}[] = [];
residenceTypes = [
'Owned',
'Rented',
@@ -386,7 +401,18 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
state: addr.stateName || '',
landmark: addr.street || ''
});
// Saved address payloads don't include the PIN's full locality list
// or the "verified" status — so the dropdown shows only the saved
// value and the green "PIN verified" chip never appears. Re-run the
// lookup silently to repopulate options and confirm the PIN, without
// overwriting what the user already had on record.
if ((addr.postalCode || '').toString().length === 6) {
this.silentlyHydratePin(tab);
}
}
// Surface the address proof file the user uploaded previously.
this.loadExistingAddressProof();
},
error: () => {
this.addressLoaded = true;
@@ -394,6 +420,95 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
/**
* Address proof on this UI is persisted as a client_identifier row whose documentType
* is one of the address-proof types (see create-client.saveAddressProof). To restore
* the picker state and show the existing file in edit mode we:
* 1. List the client's identifiers
* 2. Find the one whose documentType matches an entry in `addressProofs`
* 3. Patch the permanent-form proofId / documentKey from it
* 4. Fetch its attached documents and render the first image as a preview
*/
private loadExistingAddressProof(): void {
if (!this.clientId) return;
// Wait briefly for proof types to load if they're still in flight — they're needed
// to filter identifiers down to "address proofs" only.
if (this.loadingProofs || this.addressProofs.length === 0) {
setTimeout(() => this.loadExistingAddressProof(), 200);
return;
}
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) return;
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
const identifier = identifiers.find((i: any) => {
const n = i?.documentType?.name?.toLowerCase();
return n && proofNames.has(n);
});
if (!identifier?.id) return;
this.existingAddressProofIdentifierId = identifier.id.toString();
// Restore selected chip + form values
const matchedProof = this.addressProofs.find(
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
);
if (matchedProof) {
this.selectedAddrProof = matchedProof;
this.permanentForm.patchValue({
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
// create-client.saveAddressProof) — pull it out so the form's issueDate field
// shows what was previously entered.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
if (issueMatch) {
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
}
}
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
next: (docs: any[]) => {
if (!docs || docs.length === 0) return;
this.existingAddressProofDocuments = docs.map((d: any) => ({
id: d.id?.toString() || '',
name: d.name || '',
fileName: d.fileName || '',
type: d.type || '',
size: d.size || 0,
previewUrl: null as string | null
}));
const firstImage = this.existingAddressProofDocuments.find((d) =>
(d.type || '').toLowerCase().startsWith('image/')
);
if (firstImage) {
this.clientsService
.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, firstImage.id)
.subscribe({
next: async (blob: any) => {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
firstImage.previewUrl = 'data:' + blob.type + ';base64,' + btoa(binary);
if (!this.addrMergedPreview) {
this.addrMergedPreview = firstImage.previewUrl;
}
},
error: () => {}
});
}
},
error: () => {}
});
},
error: () => {}
});
}
onPinInput(tab: string) {
const form = this.getForm(tab);
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
@@ -451,12 +566,121 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
/**
* Open the saved address proof in a new browser tab.
*
* For images we already hold a base64 data URL in `previewUrl` from
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
* For PDFs / other types we need to fetch the binary blob and create a
* temporary object URL so the browser's PDF viewer can render it.
*/
viewExistingAddressProof(): void {
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
return;
}
const doc = this.existingAddressProofDocuments[0];
if (doc.previewUrl) {
window.open(doc.previewUrl, '_blank', 'noopener');
return;
}
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
// Revoke after the tab has had time to load — browsers vary, so
// err on the side of a generous wait.
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
},
error: () => {
// Silent fail — UI shows the saved name so user knows it exists.
}
});
}
private clearPinFields(tab: string) {
this.localityOptions[tab] = [];
const form = this.getForm(tab);
form.patchValue({ locality: '', city: '', district: '', state: '' });
}
/**
* Re-hydrate the PIN lookup for an already-loaded address.
*
* Used when an existing client opens in edit mode — the form values are
* patched from the server, but the locality dropdown options and the
* "PIN verified" status are derived state that only `onPinInput` produces.
* This variant populates that derived state WITHOUT clobbering the saved
* locality / city / district / state the user originally chose.
*/
private silentlyHydratePin(tab: string): void {
const form = this.getForm(tab);
const pin = (form.get('pinCode')?.value || '').toString().replace(/\D/g, '');
if (pin.length !== 6) return;
const applyOptions = (offices: any[]) => {
const seen = new Set<string>();
const opts = (offices || [])
.filter((o) => {
if (!o?.Name || seen.has(o.Name)) return false;
seen.add(o.Name);
return true;
})
.map((o) => o.Name);
this.localityOptions[tab] = opts;
this.pinStatus[tab] = { loading: false, found: true, error: false, message: 'Verified' };
// If the saved locality isn't part of the options (rare — e.g. spelling
// mismatch), prepend it so the bound `<mat-option>` still resolves.
const savedLocality = form.get('locality')?.value;
if (savedLocality && !opts.includes(savedLocality)) {
this.localityOptions[tab] = [
savedLocality,
...opts
];
}
// Fineract's address payload returns `stateProvinceId` (numeric) rather
// than `stateName`, so the State field arrives empty after load.
// Fill state / city / district from the PIN lookup ONLY when they're
// currently empty — preserves anything the user has actually saved.
const first = (offices || [])[0];
if (first) {
const patch: Record<string, string> = {};
if (!form.get('state')?.value && first.State) patch['state'] = first.State;
if (!form.get('city')?.value && (first.Block || first.Division || first.District)) {
patch['city'] = first.Block || first.Division || first.District;
}
if (!form.get('district')?.value && first.District) patch['district'] = first.District;
if (Object.keys(patch).length > 0) {
form.patchValue(patch);
}
}
};
if (this.pinCache[pin]) {
applyOptions(this.pinCache[pin]);
return;
}
this.pinStatus[tab] = { loading: true, found: false, error: false, message: 'Looking up...' };
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
next: (data: any) => {
if (data?.[0]?.Status === 'Success') {
this.pinCache[pin] = data[0].PostOffice;
applyOptions(data[0].PostOffice);
} else {
// Couldn't confirm — keep the saved value visible but flag it.
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
}
},
error: () => {
this.pinStatus[tab] = { loading: false, found: false, error: false, message: '' };
}
});
}
getForm(tab: string): UntypedFormGroup {
switch (tab) {
case 'permanent':
@@ -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">
@@ -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,