fix(clients): rebuild CKYC document upload as a popup with full proof details

The CKYC tab's document cards now open a dedicated dialog (with client-side
preview) instead of expanding inline, collecting document type, number, and
issue/expiry dates the same way client creation does. Fixes several bugs
found along the way:

- Uploading a document type that's valid for both identity and address
  (Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint"
  and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are
  both resolved.
- Edit Client's Identity-proof and Address-proof steps no longer show each
  other's document when both are dual-purpose OVD types, and no longer show
  stale/mismatched front-back scans after a document type change.
- Address-proof's own document preview never showed a document at all when
  no m_client_kyc record existed yet.

Document number and issue/expiry dates are persisted on the identifier
(documentKey + a shared "Issue date: ..., Expiry date: ..." description
convention) and read back by both the CKYC tab and Edit Client so the same
details appear everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:39:14 +05:30
co-authored by Claude Sonnet 5
parent ab58d7f74c
commit 31e99b1363
13 changed files with 1364 additions and 371 deletions
@@ -221,6 +221,22 @@
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
</mat-form-field>
}
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Expiry date (optional)</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>event_busy</mat-icon></span>
<input
matInput
[matDatepicker]="addrExpiryDatePicker"
formControlName="expiryDate"
[readonly]="readOnly || isAddressProofLinkedToKyc"
/>
<mat-datepicker-toggle
matSuffix
[for]="addrExpiryDatePicker"
[disabled]="readOnly || isAddressProofLinkedToKyc"
></mat-datepicker-toggle>
<mat-datepicker #addrExpiryDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
@@ -238,14 +254,18 @@
@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" />
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
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 }}
{{ existingAddrFrontSlotName }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -278,14 +298,18 @@
<!-- 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" />
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
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 }}
{{ existingAddrFrontSlotName }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -327,14 +351,18 @@
@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" />
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
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 }}
{{ existingAddrBackSlotName }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -367,14 +395,18 @@
<!-- 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" />
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
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 }}
{{ existingAddrBackSlotName }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -102,7 +102,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
Validators.required
],
documentKey: [''],
issueDate: ['']
issueDate: [''],
expiryDate: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
@@ -222,6 +223,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
size: number;
previewUrl: string | null;
}[] = [];
/** Set only when the saved documents are separately-uploaded "<Proof> — Front"/"— Back"
* files (the CKYC tab / client-kyc-step convention) rather than this component's own single
* merged-PNG upload — lets the two slots show their own file instead of both showing
* whichever one happened to be first. */
private existingAddrFrontPreview: string | null = null;
private existingAddrBackPreview: string | null = null;
residenceTypes = [
'Owned',
@@ -307,11 +314,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.existingAddressProofDocuments = [];
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
if (this.existingKycDocSides.front?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
} else if (this.existingKycDocSides.back?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
}
// These already carry their own per-side preview from the KYC step's own mapping —
// keep the two slots distinct rather than collapsing them into one shared preview.
this.existingAddrFrontPreview = this.existingKycDocSides.front?.previewUrl || null;
this.existingAddrBackPreview = this.existingKycDocSides.back?.previewUrl || null;
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
}
} else if (this.isAddressProofLinkedToKyc) {
@@ -327,6 +333,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.currentForm.patchValue({ documentKey: '', issueDate: null });
this.addrFiles = { front: null, back: null };
this.addrMergedPreview = null;
this.existingAddrFrontPreview = null;
this.existingAddrBackPreview = null;
}
/**
@@ -356,6 +364,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
this.existingAddrFrontPreview = null;
this.existingAddrBackPreview = null;
this.existingAddressProofIdentifierId = null;
this.existingAddressProofDocuments = [];
this.addressLoaded = false;
@@ -488,6 +498,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: any;
documentKey: string;
issueDate: string;
expiryDate: string;
files: { slot: string; file: File }[];
} {
const files: { slot: string; file: File }[] = [];
@@ -500,11 +511,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
const rawExpiryDate = this.permanentForm.get('expiryDate')?.value;
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
expiryDate: rawExpiryDate ? this.dates.getDate(rawExpiryDate) : '',
files
};
}
@@ -644,7 +657,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.tryLinkKycData();
return;
}
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for both this slot and
// the separate identity-proof one (client-kyc-step) — when a client has two such
// dual-purpose identifiers, that step always runs first and picks the OLDEST match (the
// one on file first, historically the identity proof), so picking the NEWEST match here
// lands this step on the other row without the two steps needing to coordinate.
const identifier = [...identifiers]
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))
.find((i: any) => this.findMatchingProof(i?.documentType?.name));
if (!identifier?.id) {
this.existingAddressProofCheckComplete = true;
this.tryLinkKycData();
@@ -664,14 +684,19 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
// create-client.saveAddressProof) — pull it out so the form's issueDate field
// shows what was previously entered.
// The description carries "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" when set on
// create (see create-client.saveAddressProof / CkycTabComponent) — pull both out so the
// form's date fields show what was previously entered. Each match stops at the next
// comma so adding expiry after issue doesn't get swallowed into the issue-date capture.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
if (issueMatch) {
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
}
if (expiryMatch) {
this.permanentForm.patchValue({ expiryDate: this.dates.parseDate(expiryMatch[1].trim()) });
}
}
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
@@ -685,6 +710,29 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
size: d.size || 0,
previewUrl: null as string | null
}));
const sideOf = (d: { name: string; fileName: string }): 'front' | 'back' | null => {
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
if (text.includes('back')) return 'back';
if (text.includes('front')) return 'front';
return null;
};
// Changing the document type (e.g. Aadhaar -> Passport) keeps the old front/back
// files attached to this same identifier rather than removing them, so there can be
// more than one "— Front"/"— Back" pair on file — sort newest-first before matching
// or this would keep showing the very first scan ever uploaded here.
const byNewest = [...this.existingAddressProofDocuments].sort((a, b) => Number(b.id) - Number(a.id));
const frontDoc = byNewest.find((d) => sideOf(d) === 'front');
const backDoc = byNewest.find((d) => sideOf(d) === 'back');
if (frontDoc || backDoc) {
// Separately-uploaded front/back — each side gets its own preview.
if (frontDoc) this.downloadAddrSidePreview(frontDoc, 'front');
if (backDoc) this.downloadAddrSidePreview(backDoc, 'back');
return;
}
// Legacy/single-file case: one combined (or unlabelled) document covers both slots.
const firstImage = this.existingAddressProofDocuments.find((d) =>
(d.type || '').toLowerCase().startsWith('image/')
);
@@ -715,6 +763,50 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
private downloadAddrSidePreview(doc: { id: string; fileName: string; name: string }, slot: 'front' | 'back'): void {
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, doc.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]);
}
const url = 'data:' + blob.type + ';base64,' + btoa(binary);
if (slot === 'front') this.existingAddrFrontPreview = url;
else this.existingAddrBackPreview = url;
},
error: () => {}
});
}
/** What the Front slot should show: its own saved file when front/back were uploaded
* separately, falling back to the single combined preview for legacy/merged uploads. */
get existingAddrFrontSlotPreview(): string | null {
return this.existingAddrFrontPreview || this.addrMergedPreview;
}
get existingAddrFrontSlotName(): string {
const front = this.existingAddressProofDocuments.find((d) =>
`${d.name} ${d.fileName}`.toLowerCase().includes('front')
);
const doc = front || this.existingAddressProofDocuments[0];
return doc?.fileName || doc?.name || '';
}
/** Same as above, for the Back slot. */
get existingAddrBackSlotPreview(): string | null {
return this.existingAddrBackPreview || this.addrMergedPreview;
}
get existingAddrBackSlotName(): string {
const back = this.existingAddressProofDocuments.find((d) =>
`${d.name} ${d.fileName}`.toLowerCase().includes('back')
);
const doc = back || this.existingAddressProofDocuments[0];
return doc?.fileName || doc?.name || '';
}
onPinInput(tab: string) {
const form = this.getForm(tab);
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
@@ -7,6 +7,7 @@
*/
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
MatDialog,
@@ -1266,8 +1267,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.loadExistingKycIdentifier(data?.kycDocumentName);
},
error: () => {
// No existing KYC — that's fine for new clients
// No m_client_kyc row yet (e.g. CERSAI sync hasn't run) — that's fine, but the client
// can still have a KYC-type identifier already on file (uploaded via the CKYC tab,
// which writes straight to client_identifier and never touches this record). Look it
// up the same way the success branch does, just without a document-name hint.
this.kycLoaded = true;
this.loadExistingPhoto();
this.loadExistingSignature();
this.loadExistingKycIdentifier(undefined);
}
});
}
@@ -1283,8 +1290,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
*/
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
if (!this.clientId) return;
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
forkJoin({
identifiers: this.clientsService.getClientIdentifiers(this.clientId),
// Only needed to spot the dual-purpose OVD types (Aadhaar/Passport/Voter ID/Driving
// Licence) below — a failure here just means that disambiguation step is skipped.
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ addressProofs: [] })))
}).subscribe({
next: ({ identifiers, proofTypes }: { identifiers: any[]; proofTypes: any }) => {
if (!identifiers || identifiers.length === 0) return;
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const isKycType = (i: any) => {
@@ -1297,6 +1309,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
})
);
};
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for BOTH this slot and
// the separate address-proof one (client-india-address-step) — a client with, say, a PAN
// identity proof and an Aadhaar address proof has two client_identifier rows that both
// pass isKycType. Without a name to go on, prefer whichever candidate is NOT also an
// address-proof type, so this slot doesn't grab the other step's file.
const addressTypeNames = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => cleanStr(p?.name)));
const isAddressType = (i: any) => addressTypeNames.has(cleanStr(i?.documentType?.name));
// Re-uploading a KYC document creates a new identifier row rather than updating the
// old one in place (same pattern as OVD identifiers), so sort newest-first before
// picking — otherwise a repeat upload of the same document type would keep showing
@@ -1311,7 +1330,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return name === kName || name.includes(kName) || kName.includes(name);
})
: null;
const identifier = match || sorted.find(isKycType);
// Last resort, when EVERY kyc-type candidate is also address-eligible (e.g. a client
// whose identity proof and address proof are both Aadhaar/Passport/Voter ID/Driving
// Licence): this stepper always runs before the address step in both the create and
// edit wizards, so the identity proof is the one that was on file first — pick the
// OLDEST match here, and the address step (client-india-address-step) picks the NEWEST
// among ITS own address-eligible candidates, so the two steps land on different rows
// without needing to coordinate at runtime.
const identifier =
match || sorted.find((i) => isKycType(i) && !isAddressType(i)) || [...sorted].reverse().find(isKycType);
if (!identifier?.id) return;
this.existingKycIdentifierId = identifier.id.toString();
@@ -1327,6 +1354,19 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentNumber: identifier.documentKey || ''
});
}
// Same for issue/expiry date — the CKYC tab (and client-india-address-step, for address
// proof) write "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" into the identifier's
// description; read it back here only to fill in whatever the KYC record itself doesn't
// already have, same as the documentKey backfill above.
const desc: string = identifier?.description || '';
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
if (issueMatch && !this.kycForm.get('kycDocIssueDate')?.value) {
this.kycForm.patchValue({ kycDocIssueDate: this.parseApiDate(issueMatch[1].trim()) });
}
if (expiryMatch && !this.kycForm.get('kycDocExpiryDate')?.value) {
this.kycForm.patchValue({ kycDocExpiryDate: this.parseApiDate(expiryMatch[1].trim()) });
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
next: (docs: any[]) => {