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:
+44
-12
@@ -221,6 +221,22 @@
|
|||||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</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>
|
||||||
|
|
||||||
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
|
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
|
||||||
@@ -238,14 +254,18 @@
|
|||||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
|
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
|
||||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||||
<div class="addr-dropzone__existing">
|
<div class="addr-dropzone__existing">
|
||||||
@if (addrMergedPreview) {
|
@if (existingAddrFrontSlotPreview) {
|
||||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
<img
|
||||||
|
[src]="existingAddrFrontSlotPreview"
|
||||||
|
alt="Saved address proof"
|
||||||
|
class="addr-dropzone__thumb"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||||
}
|
}
|
||||||
<div class="addr-dropzone__existing-meta">
|
<div class="addr-dropzone__existing-meta">
|
||||||
<span class="addr-dropzone__existing-name">
|
<span class="addr-dropzone__existing-name">
|
||||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
{{ existingAddrFrontSlotName }}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||||
<mat-icon>visibility</mat-icon>
|
<mat-icon>visibility</mat-icon>
|
||||||
@@ -278,14 +298,18 @@
|
|||||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||||
click anywhere to replace. -->
|
click anywhere to replace. -->
|
||||||
<div class="addr-dropzone__existing">
|
<div class="addr-dropzone__existing">
|
||||||
@if (addrMergedPreview) {
|
@if (existingAddrFrontSlotPreview) {
|
||||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
<img
|
||||||
|
[src]="existingAddrFrontSlotPreview"
|
||||||
|
alt="Saved address proof"
|
||||||
|
class="addr-dropzone__thumb"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||||
}
|
}
|
||||||
<div class="addr-dropzone__existing-meta">
|
<div class="addr-dropzone__existing-meta">
|
||||||
<span class="addr-dropzone__existing-name">
|
<span class="addr-dropzone__existing-name">
|
||||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
{{ existingAddrFrontSlotName }}
|
||||||
</span>
|
</span>
|
||||||
<div class="addr-dropzone__existing-actions">
|
<div class="addr-dropzone__existing-actions">
|
||||||
<button
|
<button
|
||||||
@@ -327,14 +351,18 @@
|
|||||||
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
|
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
|
||||||
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
|
||||||
<div class="addr-dropzone__existing">
|
<div class="addr-dropzone__existing">
|
||||||
@if (addrMergedPreview) {
|
@if (existingAddrBackSlotPreview) {
|
||||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
<img
|
||||||
|
[src]="existingAddrBackSlotPreview"
|
||||||
|
alt="Saved address proof"
|
||||||
|
class="addr-dropzone__thumb"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||||
}
|
}
|
||||||
<div class="addr-dropzone__existing-meta">
|
<div class="addr-dropzone__existing-meta">
|
||||||
<span class="addr-dropzone__existing-name">
|
<span class="addr-dropzone__existing-name">
|
||||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
{{ existingAddrBackSlotName }}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
|
||||||
<mat-icon>visibility</mat-icon>
|
<mat-icon>visibility</mat-icon>
|
||||||
@@ -367,14 +395,18 @@
|
|||||||
<!-- Edit mode WITH existing combined doc — same view + replace
|
<!-- Edit mode WITH existing combined doc — same view + replace
|
||||||
pattern as the front slot, anchored to the saved file. -->
|
pattern as the front slot, anchored to the saved file. -->
|
||||||
<div class="addr-dropzone__existing">
|
<div class="addr-dropzone__existing">
|
||||||
@if (addrMergedPreview) {
|
@if (existingAddrBackSlotPreview) {
|
||||||
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
|
<img
|
||||||
|
[src]="existingAddrBackSlotPreview"
|
||||||
|
alt="Saved address proof"
|
||||||
|
class="addr-dropzone__thumb"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
|
||||||
}
|
}
|
||||||
<div class="addr-dropzone__existing-meta">
|
<div class="addr-dropzone__existing-meta">
|
||||||
<span class="addr-dropzone__existing-name">
|
<span class="addr-dropzone__existing-name">
|
||||||
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
|
{{ existingAddrBackSlotName }}
|
||||||
</span>
|
</span>
|
||||||
<div class="addr-dropzone__existing-actions">
|
<div class="addr-dropzone__existing-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+103
-11
@@ -102,7 +102,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
documentKey: [''],
|
documentKey: [''],
|
||||||
issueDate: ['']
|
issueDate: [''],
|
||||||
|
expiryDate: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
currentForm: UntypedFormGroup = this.fb.group({
|
currentForm: UntypedFormGroup = this.fb.group({
|
||||||
@@ -222,6 +223,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
size: number;
|
size: number;
|
||||||
previewUrl: string | null;
|
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 = [
|
residenceTypes = [
|
||||||
'Owned',
|
'Owned',
|
||||||
@@ -307,11 +314,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
this.existingAddressProofDocuments = [];
|
this.existingAddressProofDocuments = [];
|
||||||
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
|
||||||
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
|
||||||
if (this.existingKycDocSides.front?.previewUrl) {
|
// These already carry their own per-side preview from the KYC step's own mapping —
|
||||||
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
|
// keep the two slots distinct rather than collapsing them into one shared preview.
|
||||||
} else if (this.existingKycDocSides.back?.previewUrl) {
|
this.existingAddrFrontPreview = this.existingKycDocSides.front?.previewUrl || null;
|
||||||
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
|
this.existingAddrBackPreview = this.existingKycDocSides.back?.previewUrl || null;
|
||||||
}
|
|
||||||
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
|
||||||
}
|
}
|
||||||
} else if (this.isAddressProofLinkedToKyc) {
|
} else if (this.isAddressProofLinkedToKyc) {
|
||||||
@@ -327,6 +333,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
this.currentForm.patchValue({ documentKey: '', issueDate: null });
|
||||||
this.addrFiles = { front: null, back: null };
|
this.addrFiles = { front: null, back: null };
|
||||||
this.addrMergedPreview = 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.addrFiles = { front: null, back: null };
|
||||||
this.addrMergedFile = null;
|
this.addrMergedFile = null;
|
||||||
this.addrMergedPreview = null;
|
this.addrMergedPreview = null;
|
||||||
|
this.existingAddrFrontPreview = null;
|
||||||
|
this.existingAddrBackPreview = null;
|
||||||
this.existingAddressProofIdentifierId = null;
|
this.existingAddressProofIdentifierId = null;
|
||||||
this.existingAddressProofDocuments = [];
|
this.existingAddressProofDocuments = [];
|
||||||
this.addressLoaded = false;
|
this.addressLoaded = false;
|
||||||
@@ -488,6 +498,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
proofId: any;
|
proofId: any;
|
||||||
documentKey: string;
|
documentKey: string;
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
|
expiryDate: string;
|
||||||
files: { slot: string; file: File }[];
|
files: { slot: string; file: File }[];
|
||||||
} {
|
} {
|
||||||
const 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 rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||||
|
const rawExpiryDate = this.permanentForm.get('expiryDate')?.value;
|
||||||
return {
|
return {
|
||||||
proof: this.selectedAddrProof,
|
proof: this.selectedAddrProof,
|
||||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||||
|
expiryDate: rawExpiryDate ? this.dates.getDate(rawExpiryDate) : '',
|
||||||
files
|
files
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -644,7 +657,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
this.tryLinkKycData();
|
this.tryLinkKycData();
|
||||||
return;
|
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) {
|
if (!identifier?.id) {
|
||||||
this.existingAddressProofCheckComplete = true;
|
this.existingAddressProofCheckComplete = true;
|
||||||
this.tryLinkKycData();
|
this.tryLinkKycData();
|
||||||
@@ -664,14 +684,19 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
proofId: matchedProof.id,
|
proofId: matchedProof.id,
|
||||||
documentKey: identifier.documentKey || ''
|
documentKey: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
// The description carries "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" when set on
|
||||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
// create (see create-client.saveAddressProof / CkycTabComponent) — pull both out so the
|
||||||
// shows what was previously entered.
|
// 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 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) {
|
if (issueMatch) {
|
||||||
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
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({
|
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
|
||||||
@@ -685,6 +710,29 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
size: d.size || 0,
|
size: d.size || 0,
|
||||||
previewUrl: null as string | null
|
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) =>
|
const firstImage = this.existingAddressProofDocuments.find((d) =>
|
||||||
(d.type || '').toLowerCase().startsWith('image/')
|
(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) {
|
onPinInput(tab: string) {
|
||||||
const form = this.getForm(tab);
|
const form = this.getForm(tab);
|
||||||
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
|
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 { 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 { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
MatDialog,
|
MatDialog,
|
||||||
@@ -1266,8 +1267,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
this.loadExistingKycIdentifier(data?.kycDocumentName);
|
||||||
},
|
},
|
||||||
error: () => {
|
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.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 {
|
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
|
||||||
if (!this.clientId) return;
|
if (!this.clientId) return;
|
||||||
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
|
forkJoin({
|
||||||
next: (identifiers: any[]) => {
|
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;
|
if (!identifiers || identifiers.length === 0) return;
|
||||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
const isKycType = (i: any) => {
|
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
|
// 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
|
// 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
|
// 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);
|
return name === kName || name.includes(kName) || kName.includes(name);
|
||||||
})
|
})
|
||||||
: null;
|
: 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;
|
if (!identifier?.id) return;
|
||||||
|
|
||||||
this.existingKycIdentifierId = identifier.id.toString();
|
this.existingKycIdentifierId = identifier.id.toString();
|
||||||
@@ -1327,6 +1354,19 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocumentNumber: identifier.documentKey || ''
|
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({
|
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||||
next: (docs: any[]) => {
|
next: (docs: any[]) => {
|
||||||
|
|||||||
@@ -204,6 +204,24 @@
|
|||||||
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
|
||||||
|
<div class="ckyc-existing__thumbs">
|
||||||
|
@if (existing.ADDRESS_PROOF.previewUrl) {
|
||||||
|
<img
|
||||||
|
[src]="existing.ADDRESS_PROOF.previewUrl"
|
||||||
|
alt="Address proof scan - front"
|
||||||
|
class="ckyc-existing__thumb"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (existing.ADDRESS_PROOF.previewUrlBack) {
|
||||||
|
<img
|
||||||
|
[src]="existing.ADDRESS_PROOF.previewUrlBack"
|
||||||
|
alt="Address proof scan - back"
|
||||||
|
class="ckyc-existing__thumb"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ckyc-existing__meta">
|
<div class="ckyc-existing__meta">
|
||||||
<fa-icon icon="file-alt"></fa-icon>
|
<fa-icon icon="file-alt"></fa-icon>
|
||||||
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
||||||
@@ -217,78 +235,51 @@
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (cardDate('ADDRESS_PROOF', 'issue') || cardDate('ADDRESS_PROOF', 'expiry')) {
|
||||||
|
<div class="ckyc-existing__dates">
|
||||||
|
@if (cardDate('ADDRESS_PROOF', 'issue')) {
|
||||||
|
<span
|
||||||
|
><fa-icon icon="calendar-check"></fa-icon> Issued {{ cardDate('ADDRESS_PROOF', 'issue') }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@if (cardDate('ADDRESS_PROOF', 'expiry')) {
|
||||||
|
<span
|
||||||
|
><fa-icon icon="calendar-check"></fa-icon> Expires {{ cardDate('ADDRESS_PROOF', 'expiry') }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="ckyc-existing__replace"
|
class="ckyc-existing__replace"
|
||||||
(click)="toggleReplace('ADDRESS_PROOF')"
|
(click)="openUploadDialog('ADDRESS_PROOF')"
|
||||||
>
|
>
|
||||||
<fa-icon [icon]="existing.ADDRESS_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
|
<fa-icon icon="upload"></fa-icon>
|
||||||
{{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
|
Upload new version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ckyc-missing">
|
||||||
|
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF</p>
|
||||||
|
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('ADDRESS_PROOF')">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
|
@if (uploading['ADDRESS_PROOF']) {
|
||||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
|
<div class="ckyc-uploading">
|
||||||
<div class="ckyc-file-row">
|
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||||
<input
|
<span>Uploading…</span>
|
||||||
type="file"
|
</div>
|
||||||
#addressProofInput
|
}
|
||||||
accept=".png,.jpeg,.jpg,.pdf"
|
@if (uploadErrors['ADDRESS_PROOF']) {
|
||||||
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
|
<div class="ckyc-upload-error">
|
||||||
[style.display]="'none'"
|
<mat-icon>error_outline</mat-icon>
|
||||||
/>
|
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
|
||||||
<mat-form-field appearance="outline" class="ckyc-file-name-field">
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[readonly]="true"
|
|
||||||
[value]="addressProofFile?.name || ''"
|
|
||||||
[placeholder]="'labels.text.NoFileSelected' | translate"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
|
|
||||||
<fa-icon icon="folder-open" size="lg"></fa-icon>
|
|
||||||
{{ 'labels.buttons.Browse' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
|
|
||||||
(click)="uploadDocument('ADDRESS_PROOF')"
|
|
||||||
>
|
|
||||||
@if (uploading['ADDRESS_PROOF']) {
|
|
||||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (uploadErrors['ADDRESS_PROOF']) {
|
|
||||||
<div class="ckyc-upload-error">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (uploadResponses['ADDRESS_PROOF']) {
|
|
||||||
<div class="ckyc-upload-response">
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
|
|
||||||
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
|
|
||||||
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -310,6 +301,24 @@
|
|||||||
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
|
||||||
|
<div class="ckyc-existing__thumbs">
|
||||||
|
@if (existing.PHOTO_PROOF.previewUrl) {
|
||||||
|
<img
|
||||||
|
[src]="existing.PHOTO_PROOF.previewUrl"
|
||||||
|
alt="ID proof scan - front"
|
||||||
|
class="ckyc-existing__thumb"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (existing.PHOTO_PROOF.previewUrlBack) {
|
||||||
|
<img
|
||||||
|
[src]="existing.PHOTO_PROOF.previewUrlBack"
|
||||||
|
alt="ID proof scan - back"
|
||||||
|
class="ckyc-existing__thumb"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ckyc-existing__meta">
|
<div class="ckyc-existing__meta">
|
||||||
<fa-icon icon="id-card"></fa-icon>
|
<fa-icon icon="id-card"></fa-icon>
|
||||||
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
||||||
@@ -322,78 +331,49 @@
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (cardDate('PHOTO_PROOF', 'issue') || cardDate('PHOTO_PROOF', 'expiry')) {
|
||||||
|
<div class="ckyc-existing__dates">
|
||||||
|
@if (cardDate('PHOTO_PROOF', 'issue')) {
|
||||||
|
<span><fa-icon icon="calendar-check"></fa-icon> Issued {{ cardDate('PHOTO_PROOF', 'issue') }}</span>
|
||||||
|
}
|
||||||
|
@if (cardDate('PHOTO_PROOF', 'expiry')) {
|
||||||
|
<span
|
||||||
|
><fa-icon icon="calendar-check"></fa-icon> Expires {{ cardDate('PHOTO_PROOF', 'expiry') }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="ckyc-existing__replace"
|
class="ckyc-existing__replace"
|
||||||
(click)="toggleReplace('PHOTO_PROOF')"
|
(click)="openUploadDialog('PHOTO_PROOF')"
|
||||||
>
|
>
|
||||||
<fa-icon [icon]="existing.PHOTO_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
|
<fa-icon icon="upload"></fa-icon>
|
||||||
{{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
|
Upload new version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ckyc-missing">
|
||||||
|
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF</p>
|
||||||
|
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('PHOTO_PROOF')">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
|
@if (uploading['PHOTO_PROOF']) {
|
||||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
|
<div class="ckyc-uploading">
|
||||||
<div class="ckyc-file-row">
|
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||||
<input
|
<span>Uploading…</span>
|
||||||
type="file"
|
</div>
|
||||||
#photoProofInput
|
}
|
||||||
accept=".png,.jpeg,.jpg,.pdf"
|
@if (uploadErrors['PHOTO_PROOF']) {
|
||||||
(change)="onFileSelect($event, 'PHOTO_PROOF')"
|
<div class="ckyc-upload-error">
|
||||||
[style.display]="'none'"
|
<mat-icon>error_outline</mat-icon>
|
||||||
/>
|
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
|
||||||
<mat-form-field appearance="outline" class="ckyc-file-name-field">
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[readonly]="true"
|
|
||||||
[value]="photoProofFile?.name || ''"
|
|
||||||
[placeholder]="'labels.text.NoFileSelected' | translate"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
|
|
||||||
<fa-icon icon="folder-open" size="lg"></fa-icon>
|
|
||||||
{{ 'labels.buttons.Browse' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
|
|
||||||
(click)="uploadDocument('PHOTO_PROOF')"
|
|
||||||
>
|
|
||||||
@if (uploading['PHOTO_PROOF']) {
|
|
||||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (uploadErrors['PHOTO_PROOF']) {
|
|
||||||
<div class="ckyc-upload-error">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (uploadResponses['PHOTO_PROOF']) {
|
|
||||||
<div class="ckyc-upload-response">
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
|
|
||||||
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
|
|
||||||
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -422,74 +402,33 @@
|
|||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="ckyc-existing__replace"
|
class="ckyc-existing__replace"
|
||||||
(click)="toggleReplace('CLIENT_IMAGE')"
|
(click)="openUploadDialog('CLIENT_IMAGE')"
|
||||||
>
|
>
|
||||||
<fa-icon [icon]="existing.CLIENT_IMAGE.expanded ? 'times' : 'upload'"></fa-icon>
|
<fa-icon icon="upload"></fa-icon>
|
||||||
{{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
|
Upload new version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ckyc-missing">
|
||||||
|
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG</p>
|
||||||
|
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('CLIENT_IMAGE')">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
|
@if (uploading['CLIENT_IMAGE']) {
|
||||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
|
<div class="ckyc-uploading">
|
||||||
<div class="ckyc-file-row">
|
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||||
<input
|
<span>Uploading…</span>
|
||||||
type="file"
|
</div>
|
||||||
#clientImageInput
|
}
|
||||||
accept=".png,.jpeg,.jpg"
|
@if (uploadErrors['CLIENT_IMAGE']) {
|
||||||
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
|
<div class="ckyc-upload-error">
|
||||||
[style.display]="'none'"
|
<mat-icon>error_outline</mat-icon>
|
||||||
/>
|
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
|
||||||
<mat-form-field appearance="outline" class="ckyc-file-name-field">
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[readonly]="true"
|
|
||||||
[value]="clientImageFile?.name || ''"
|
|
||||||
[placeholder]="'labels.text.NoFileSelected' | translate"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
|
|
||||||
<fa-icon icon="folder-open" size="lg"></fa-icon>
|
|
||||||
{{ 'labels.buttons.Browse' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
|
|
||||||
(click)="uploadDocument('CLIENT_IMAGE')"
|
|
||||||
>
|
|
||||||
@if (uploading['CLIENT_IMAGE']) {
|
|
||||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (uploadErrors['CLIENT_IMAGE']) {
|
|
||||||
<div class="ckyc-upload-error">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (uploadResponses['CLIENT_IMAGE']) {
|
|
||||||
<div class="ckyc-upload-response">
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
|
|
||||||
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
|
|
||||||
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
@@ -526,75 +465,33 @@
|
|||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="ckyc-existing__replace"
|
class="ckyc-existing__replace"
|
||||||
(click)="toggleReplace('CLIENT_SIGNATURE')"
|
(click)="openUploadDialog('CLIENT_SIGNATURE')"
|
||||||
>
|
>
|
||||||
<fa-icon [icon]="existing.CLIENT_SIGNATURE.expanded ? 'times' : 'upload'"></fa-icon>
|
<fa-icon icon="upload"></fa-icon>
|
||||||
{{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
|
Upload new version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="ckyc-missing">
|
||||||
|
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG</p>
|
||||||
|
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('CLIENT_SIGNATURE')">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
|
@if (uploading['CLIENT_SIGNATURE']) {
|
||||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
|
<div class="ckyc-uploading">
|
||||||
<div class="ckyc-file-row">
|
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||||
<input
|
<span>Uploading…</span>
|
||||||
type="file"
|
</div>
|
||||||
#clientSignatureInput
|
}
|
||||||
accept=".png,.jpeg,.jpg"
|
@if (uploadErrors['CLIENT_SIGNATURE']) {
|
||||||
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
|
<div class="ckyc-upload-error">
|
||||||
[style.display]="'none'"
|
<mat-icon>error_outline</mat-icon>
|
||||||
/>
|
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
|
||||||
<mat-form-field appearance="outline" class="ckyc-file-name-field">
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[readonly]="true"
|
|
||||||
[value]="clientSignatureFile?.name || ''"
|
|
||||||
[placeholder]="'labels.text.NoFileSelected' | translate"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
|
|
||||||
<fa-icon icon="folder-open" size="lg"></fa-icon>
|
|
||||||
{{ 'labels.buttons.Browse' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
|
|
||||||
(click)="uploadDocument('CLIENT_SIGNATURE')"
|
|
||||||
>
|
|
||||||
@if (uploading['CLIENT_SIGNATURE']) {
|
|
||||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (uploadErrors['CLIENT_SIGNATURE']) {
|
|
||||||
<div class="ckyc-upload-error">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (uploadResponses['CLIENT_SIGNATURE']) {
|
|
||||||
<div class="ckyc-upload-response">
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Request ID' | translate }}:</b>
|
|
||||||
{{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
|
|
||||||
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
|
|
||||||
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|||||||
@@ -553,15 +553,21 @@
|
|||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ckyc-file-row {
|
.ckyc-missing {
|
||||||
|
margin-top: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ckyc-file-name-field {
|
.ckyc-uploading {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5f6368;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ckyc-upload-error {
|
.ckyc-upload-error {
|
||||||
@@ -583,19 +589,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ckyc-upload-response {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
border: 1px solid #a5d6a7;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 900px) {
|
@media (width <= 900px) {
|
||||||
.ckyc-upload-grid {
|
.ckyc-upload-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -610,14 +603,6 @@
|
|||||||
.ckyc-upload-grid {
|
.ckyc-upload-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ckyc-file-row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ckyc-file-name-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Already-available banners ===================== */
|
/* ===================== Already-available banners ===================== */
|
||||||
@@ -731,6 +716,34 @@
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__thumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dates {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #0f6e56;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1d9e75;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__thumb {
|
&__thumb {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
|
|||||||
@@ -9,27 +9,38 @@
|
|||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
|
import { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { ProofType, normalizeProofType } from '../../shared/kyc-proof-types';
|
||||||
|
import { CkycUploadDialogComponent, CkycUploadDialogResult } from './ckyc-upload-dialog/ckyc-upload-dialog.component';
|
||||||
|
|
||||||
interface ExistingArtefact {
|
interface ExistingArtefact {
|
||||||
present: boolean;
|
present: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
previewUrlBack?: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
|
documentTypeId?: string;
|
||||||
documentKey?: string;
|
documentKey?: string;
|
||||||
|
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention client-india-address-step
|
||||||
|
* already writes/reads for address proof; now shared by the photo/identity proof too. */
|
||||||
|
description?: string;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
expanded?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
|
||||||
|
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-ckyc-tab',
|
selector: 'mifosx-ckyc-tab',
|
||||||
templateUrl: './ckyc-tab.component.html',
|
templateUrl: './ckyc-tab.component.html',
|
||||||
@@ -46,6 +57,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private alertService = inject(AlertService);
|
private alertService = inject(AlertService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private dates = inject(Dates);
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@@ -80,16 +93,41 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pre-existing artefacts detected from client data */
|
/** Pre-existing artefacts detected from client data */
|
||||||
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
|
existing: Record<CkycDocKind, ExistingArtefact> = {
|
||||||
ADDRESS_PROOF: { present: false },
|
ADDRESS_PROOF: { present: false },
|
||||||
PHOTO_PROOF: { present: false },
|
PHOTO_PROOF: { present: false },
|
||||||
CLIENT_IMAGE: { present: false },
|
CLIENT_IMAGE: { present: false },
|
||||||
CLIENT_SIGNATURE: { present: false }
|
CLIENT_SIGNATURE: { present: false }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Document-type options for the proof pickers, fetched once from /kyc/proof-types */
|
||||||
|
addressProofOptions: ProofType[] = [];
|
||||||
|
idProofOptions: ProofType[] = [];
|
||||||
|
|
||||||
|
/** Document type currently selected in each proof card's picker */
|
||||||
|
selectedProofType: Record<ProofKind, ProofType | null> = {
|
||||||
|
ADDRESS_PROOF: null,
|
||||||
|
PHOTO_PROOF: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Front/back files staged for the two proof cards; other cards keep a single file each */
|
||||||
|
docFiles: Record<ProofKind, { front: File | null; back: File | null }> = {
|
||||||
|
ADDRESS_PROOF: { front: null, back: null },
|
||||||
|
PHOTO_PROOF: { front: null, back: null }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Document number + issue/expiry dates staged from the upload dialog, same fields client
|
||||||
|
* creation collects for a KYC/address proof — persisted onto the identifier's documentKey
|
||||||
|
* and description ("Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd"). */
|
||||||
|
private documentDetails: Record<
|
||||||
|
ProofKind,
|
||||||
|
{ documentKey: string; issueDate: string | null; expiryDate: string | null }
|
||||||
|
> = {
|
||||||
|
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
|
||||||
|
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
|
||||||
|
};
|
||||||
|
|
||||||
/** File references */
|
/** File references */
|
||||||
addressProofFile: File | null = null;
|
|
||||||
photoProofFile: File | null = null;
|
|
||||||
clientImageFile: File | null = null;
|
clientImageFile: File | null = null;
|
||||||
clientSignatureFile: File | null = null;
|
clientSignatureFile: File | null = null;
|
||||||
|
|
||||||
@@ -101,9 +139,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
CLIENT_SIGNATURE: false
|
CLIENT_SIGNATURE: false
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Per-document upload response */
|
|
||||||
uploadResponses: { [key: string]: any } = {};
|
|
||||||
|
|
||||||
/** Per-document error messages */
|
/** Per-document error messages */
|
||||||
uploadErrors: { [key: string]: string | null } = {
|
uploadErrors: { [key: string]: string | null } = {
|
||||||
ADDRESS_PROOF: null,
|
ADDRESS_PROOF: null,
|
||||||
@@ -262,6 +297,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
||||||
|
this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p));
|
||||||
|
this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p));
|
||||||
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
|
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
|
||||||
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
|
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
|
||||||
|
|
||||||
@@ -287,21 +324,77 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
name: proofName,
|
name: proofName,
|
||||||
documentKey: isSyntheticKey ? '' : rawKey,
|
documentKey: isSyntheticKey ? '' : rawKey,
|
||||||
documentId: identity?.id,
|
documentId: identity?.id,
|
||||||
|
documentTypeId: docTypeId,
|
||||||
|
description: identity?.description || '',
|
||||||
fileCount: (identity?.documents || []).length
|
fileCount: (identity?.documents || []).length
|
||||||
};
|
};
|
||||||
|
|
||||||
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
|
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
|
||||||
this.existing.PHOTO_PROOF = common;
|
this.existing.PHOTO_PROOF = common;
|
||||||
|
this.selectedProofType.PHOTO_PROOF = this.idProofOptions.find((p) => String(p.id) === docTypeId) || null;
|
||||||
|
this.loadIdentifierDocPreview('PHOTO_PROOF', identity.id);
|
||||||
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
|
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
|
||||||
this.existing.ADDRESS_PROOF = common;
|
this.existing.ADDRESS_PROOF = common;
|
||||||
|
this.selectedProofType.ADDRESS_PROOF =
|
||||||
|
this.addressProofOptions.find((p) => String(p.id) === docTypeId) || null;
|
||||||
|
this.loadIdentifierDocPreview('ADDRESS_PROOF', identity.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleReplace(documentType: string): void {
|
/**
|
||||||
const entry = (this.existing as any)[documentType];
|
* The scanned ID/address-proof card only showed its file name and key — never the scan
|
||||||
if (entry) entry.expanded = !entry.expanded;
|
* itself, unlike the photo and signature cards right beside it. Mirrors those two: fetch
|
||||||
|
* the identifier's own attached document(s) (m_client_identifier -> m_document, the same
|
||||||
|
* chain the Identities tab and the create-client stepper read) and download the latest for
|
||||||
|
* an inline preview. Two-sided proof types (Aadhaar, Passport, Voter ID, Driving Licence)
|
||||||
|
* store front/back as two separate documents named "<Proof> — Front"/"— Back" (the same
|
||||||
|
* convention the create-client stepper writes), so both are matched and previewed here;
|
||||||
|
* a legacy single upload with no such marker just shows as the one preview.
|
||||||
|
*/
|
||||||
|
private loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void {
|
||||||
|
this.clientsService
|
||||||
|
.getClientIdentificationDocuments(identifierId)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
catchError(() => of([]))
|
||||||
|
)
|
||||||
|
.subscribe((docs: any) => {
|
||||||
|
const list = Array.isArray(docs) ? docs : [];
|
||||||
|
if (!list.length) return;
|
||||||
|
this.existing[kind].fileCount = list.length;
|
||||||
|
|
||||||
|
const byNewest = (a: any, b: any) => b.id - a.id;
|
||||||
|
const label = (d: any) => (d?.name || d?.fileName || '').toString();
|
||||||
|
const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0];
|
||||||
|
const front =
|
||||||
|
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
|
||||||
|
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
|
||||||
|
|
||||||
|
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
|
||||||
|
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private downloadIdentifierDocPreview(
|
||||||
|
identifierId: string,
|
||||||
|
documentId: string,
|
||||||
|
kind: ProofKind,
|
||||||
|
slot: 'front' | 'back'
|
||||||
|
): void {
|
||||||
|
this.clientsService
|
||||||
|
.downloadClientIdentificationDocument(identifierId, documentId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (blob: any) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (slot === 'front') this.existing[kind].previewUrl = url;
|
||||||
|
else this.existing[kind].previewUrlBack = url;
|
||||||
|
},
|
||||||
|
error: () => undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onAccountSelect(accountNo: string): void {
|
onAccountSelect(accountNo: string): void {
|
||||||
@@ -309,33 +402,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onFileSelect(event: any, documentType: string): void {
|
|
||||||
const file = event.target?.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
switch (documentType) {
|
|
||||||
case 'ADDRESS_PROOF':
|
|
||||||
this.addressProofFile = file;
|
|
||||||
break;
|
|
||||||
case 'PHOTO_PROOF':
|
|
||||||
this.photoProofFile = file;
|
|
||||||
break;
|
|
||||||
case 'CLIENT_IMAGE':
|
|
||||||
this.clientImageFile = file;
|
|
||||||
break;
|
|
||||||
case 'CLIENT_SIGNATURE':
|
|
||||||
this.clientSignatureFile = file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.uploadErrors[documentType] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFile(documentType: string): File | null {
|
getFile(documentType: string): File | null {
|
||||||
switch (documentType) {
|
switch (documentType) {
|
||||||
case 'ADDRESS_PROOF':
|
|
||||||
return this.addressProofFile;
|
|
||||||
case 'PHOTO_PROOF':
|
|
||||||
return this.photoProofFile;
|
|
||||||
case 'CLIENT_IMAGE':
|
case 'CLIENT_IMAGE':
|
||||||
return this.clientImageFile;
|
return this.clientImageFile;
|
||||||
case 'CLIENT_SIGNATURE':
|
case 'CLIENT_SIGNATURE':
|
||||||
@@ -345,38 +413,278 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly CARD_TITLES: Record<CkycDocKind, string> = {
|
||||||
|
ADDRESS_PROOF: 'Address Proof Document',
|
||||||
|
PHOTO_PROOF: 'Photo Proof Document',
|
||||||
|
CLIENT_IMAGE: 'Image of Client',
|
||||||
|
CLIENT_SIGNATURE: 'Signature of Client'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Opens the picker/preview popup for one card and, once the user confirms a file there, feeds
|
||||||
|
* the picked type/file(s) into the same upload path the card always used. Picking and preview
|
||||||
|
* now live entirely in the dialog — this screen only stages the result and re-runs upload. */
|
||||||
|
openUploadDialog(kind: CkycDocKind): void {
|
||||||
|
const isProofKind = kind === 'ADDRESS_PROOF' || kind === 'PHOTO_PROOF';
|
||||||
|
const action = this.existing[kind].present ? 'Replace' : 'Upload';
|
||||||
|
const existingDates = isProofKind
|
||||||
|
? this.parseDescriptionDates(this.existing[kind].description)
|
||||||
|
: { issueDate: null, expiryDate: null };
|
||||||
|
|
||||||
|
const ref = this.dialog.open(CkycUploadDialogComponent, {
|
||||||
|
width: '480px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
data: {
|
||||||
|
title: `${action} ${CkycTabComponent.CARD_TITLES[kind]}`,
|
||||||
|
isProofKind,
|
||||||
|
accept: isProofKind ? '.png,.jpeg,.jpg,.pdf' : '.png,.jpeg,.jpg',
|
||||||
|
proofOptions:
|
||||||
|
kind === 'PHOTO_PROOF'
|
||||||
|
? this.idProofOptions
|
||||||
|
: kind === 'ADDRESS_PROOF'
|
||||||
|
? this.addressProofOptions
|
||||||
|
: undefined,
|
||||||
|
initialProofType: isProofKind ? this.selectedProofType[kind as ProofKind] : undefined,
|
||||||
|
initialDocumentKey: isProofKind ? this.existing[kind].documentKey || '' : undefined,
|
||||||
|
initialIssueDate: existingDates.issueDate,
|
||||||
|
initialExpiryDate: existingDates.expiryDate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
|
||||||
|
if (!result) return;
|
||||||
|
if (isProofKind) {
|
||||||
|
const proofKind = kind as ProofKind;
|
||||||
|
this.selectedProofType[proofKind] = result.proofType || null;
|
||||||
|
this.docFiles[proofKind] = { front: result.front, back: result.back || null };
|
||||||
|
this.documentDetails[proofKind] = {
|
||||||
|
documentKey: result.documentKey || '',
|
||||||
|
issueDate: result.issueDate || null,
|
||||||
|
expiryDate: result.expiryDate || null
|
||||||
|
};
|
||||||
|
} else if (kind === 'CLIENT_IMAGE') {
|
||||||
|
this.clientImageFile = result.front;
|
||||||
|
} else {
|
||||||
|
this.clientSignatureFile = result.front;
|
||||||
|
}
|
||||||
|
this.uploadDocument(kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
|
||||||
|
* client-india-address-step, for address proof) writes into the identifier's description. */
|
||||||
|
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
|
||||||
|
const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i);
|
||||||
|
const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i);
|
||||||
|
return {
|
||||||
|
issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null,
|
||||||
|
expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDescription(issueDate: string | null, expiryDate: string | null): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (issueDate) parts.push(`Issue date: ${issueDate}`);
|
||||||
|
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same
|
||||||
|
* description text the upload dialog writes. */
|
||||||
|
cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string {
|
||||||
|
const parsed = this.parseDescriptionDates(this.existing[kind].description);
|
||||||
|
const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate;
|
||||||
|
return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes each card to whichever real endpoint that artefact is actually stored through — there
|
||||||
|
* is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to
|
||||||
|
* one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the
|
||||||
|
* client's own documents, and an ID/address proof to the m_client_identifier it already belongs
|
||||||
|
* to (the same one the Identities tab and create-client stepper post to) — never a fresh,
|
||||||
|
* type-less upload, since which identifier a proof belongs to is exactly what makes it that
|
||||||
|
* proof rather than some other file on the client's record.
|
||||||
|
*/
|
||||||
uploadDocument(documentType: string): void {
|
uploadDocument(documentType: string): void {
|
||||||
|
if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') {
|
||||||
|
this.uploadProofDocument(documentType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const file = this.getFile(documentType);
|
const file = this.getFile(documentType);
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
this.uploading[documentType] = true;
|
this.uploading[documentType] = true;
|
||||||
this.uploadErrors[documentType] = null;
|
this.uploadErrors[documentType] = null;
|
||||||
|
const key = documentType as keyof typeof this.existing;
|
||||||
|
|
||||||
const formData = new FormData();
|
let request$: Observable<any>;
|
||||||
formData.append('file', file);
|
switch (documentType) {
|
||||||
formData.append('documentType', documentType);
|
case 'CLIENT_IMAGE':
|
||||||
|
request$ = this.clientsService.uploadClientProfileImage(this.clientId, file);
|
||||||
|
break;
|
||||||
|
case 'CLIENT_SIGNATURE':
|
||||||
|
request$ = this.clientsService.uploadClientSignatureImage(this.clientId, file);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.clientsService
|
request$.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
.uploadCkycDocument(this.clientId, formData)
|
next: () => {
|
||||||
.pipe(takeUntil(this.destroy$))
|
this.uploading[documentType] = false;
|
||||||
.subscribe({
|
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
|
||||||
next: (response: any) => {
|
this.refreshPreview(documentType);
|
||||||
this.uploading[documentType] = false;
|
},
|
||||||
this.uploadResponses[documentType] = response;
|
error: (err: any) => {
|
||||||
this.alertService.alert({
|
this.uploading[documentType] = false;
|
||||||
type: 'success',
|
this.uploadErrors[documentType] = this.extractErrorMessage(err);
|
||||||
message: response?.message || 'Document uploaded successfully.'
|
}
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
error: (err: any) => {
|
|
||||||
this.uploading[documentType] = false;
|
/**
|
||||||
this.uploadErrors[documentType] =
|
* Proof cards work like the create-client KYC step: pick a document type first (its `sides`
|
||||||
err?.error?.developerMessage ||
|
* decides whether one image or a front+back pair is required), then upload. Reuses the same
|
||||||
err?.error?.defaultUserMessage ||
|
* identifier the Identities tab manages — updates its documentTypeId if the picked type
|
||||||
err?.message ||
|
* changed, or creates a fresh identifier when none exists yet, so this card never depends on
|
||||||
'Failed to upload document.';
|
* a separate trip to the Identities tab.
|
||||||
|
*/
|
||||||
|
private uploadProofDocument(kind: ProofKind): void {
|
||||||
|
const proof = this.selectedProofType[kind];
|
||||||
|
if (!proof) {
|
||||||
|
this.uploadErrors[kind] = 'Select a document type first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const files = this.docFiles[kind];
|
||||||
|
if (!files.front || (proof.sides === 2 && !files.back)) {
|
||||||
|
this.uploadErrors[kind] =
|
||||||
|
proof.sides === 2
|
||||||
|
? 'Both front and back images are required for this document type.'
|
||||||
|
: 'Select a file to upload.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploading[kind] = true;
|
||||||
|
this.uploadErrors[kind] = null;
|
||||||
|
|
||||||
|
const existingId = this.existing[kind].documentId;
|
||||||
|
const details = this.documentDetails[kind];
|
||||||
|
const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`;
|
||||||
|
const description = this.buildDescription(details.issueDate, details.expiryDate);
|
||||||
|
|
||||||
|
const identifier$: Observable<any> = !existingId
|
||||||
|
? this.clientsService.addClientIdentifier(this.clientId, {
|
||||||
|
documentTypeId: proof.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
documentKey,
|
||||||
|
description: description || 'KYC document'
|
||||||
|
})
|
||||||
|
: this.clientsService.updateClientIdentifier(this.clientId, existingId, {
|
||||||
|
documentTypeId: proof.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
documentKey,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
|
||||||
|
identifier$.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const identifierId = existingId || res?.resourceId;
|
||||||
|
const uploads: Observable<any>[] = [];
|
||||||
|
|
||||||
|
const front = new FormData();
|
||||||
|
front.append('name', proof.sides === 2 ? `${proof.name} — Front` : proof.name);
|
||||||
|
front.append('file', files.front!);
|
||||||
|
uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, front));
|
||||||
|
|
||||||
|
if (proof.sides === 2 && files.back) {
|
||||||
|
const back = new FormData();
|
||||||
|
back.append('name', `${proof.name} — Back`);
|
||||||
|
back.append('file', files.back);
|
||||||
|
uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, back));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
forkJoin(uploads)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.uploading[kind] = false;
|
||||||
|
this.existing[kind].present = true;
|
||||||
|
this.existing[kind].documentId = identifierId;
|
||||||
|
this.existing[kind].documentTypeId = String(proof.id);
|
||||||
|
this.existing[kind].documentKey = details.documentKey || '';
|
||||||
|
this.existing[kind].description = description;
|
||||||
|
this.existing[kind].name = proof.name;
|
||||||
|
this.existing[kind].source = this.existing[kind].source || 'Identities section';
|
||||||
|
this.docFiles[kind] = { front: null, back: null };
|
||||||
|
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
|
||||||
|
this.refreshPreview(kind);
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.uploading[kind] = false;
|
||||||
|
this.uploadErrors[kind] = this.extractErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.uploading[kind] = false;
|
||||||
|
this.uploadErrors[kind] = this.extractErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractErrorMessage(err: any): string {
|
||||||
|
return (
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
err?.message ||
|
||||||
|
'Failed to upload document.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-fetches whichever artefact was just replaced, so its preview reflects the new file
|
||||||
|
* immediately instead of the one this screen loaded on entry. */
|
||||||
|
private refreshPreview(documentType: string): void {
|
||||||
|
const staleKey = documentType as keyof typeof this.existing;
|
||||||
|
if (this.existing[staleKey].previewUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(this.existing[staleKey].previewUrl!);
|
||||||
|
}
|
||||||
|
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
|
||||||
|
}
|
||||||
|
switch (documentType) {
|
||||||
|
case 'CLIENT_IMAGE':
|
||||||
|
this.clientsService
|
||||||
|
.getClientProfileImage(this.clientId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((photo: any) => {
|
||||||
|
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'CLIENT_SIGNATURE':
|
||||||
|
this.clientsService
|
||||||
|
.getClientDocuments(this.clientId)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((documents: any) => {
|
||||||
|
const doc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
|
if (!doc) return;
|
||||||
|
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
|
||||||
|
this.clientsService
|
||||||
|
.getClientSignatureImage(this.clientId, doc.id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((blob: any) => {
|
||||||
|
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'PHOTO_PROOF':
|
||||||
|
case 'ADDRESS_PROOF':
|
||||||
|
this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -385,6 +693,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
// Release object URLs
|
// Release object URLs
|
||||||
Object.values(this.existing).forEach((e) => {
|
Object.values(this.existing).forEach((e) => {
|
||||||
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
||||||
|
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cud-header">
|
||||||
|
<div class="cud-header__badge">
|
||||||
|
<fa-icon icon="folder-open"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="cud-header__text">
|
||||||
|
<h2 mat-dialog-title class="cud-title">{{ data.title }}</h2>
|
||||||
|
<p class="cud-subtitle">Fill in the document details, then attach the scan.</p>
|
||||||
|
</div>
|
||||||
|
<button mat-icon-button mat-dialog-close class="cud-close" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content class="cud-body">
|
||||||
|
@if (data.isProofKind) {
|
||||||
|
<div class="cud-section">
|
||||||
|
<span class="cud-section__label"><fa-icon icon="id-card"></fa-icon> Document type</span>
|
||||||
|
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
|
||||||
|
<mat-select
|
||||||
|
[value]="selectedProofType"
|
||||||
|
[compareWith]="compareProofType"
|
||||||
|
placeholder="Select document type"
|
||||||
|
(selectionChange)="onProofTypeChange($event.value)"
|
||||||
|
>
|
||||||
|
@for (p of data.proofOptions; track p.id) {
|
||||||
|
<mat-option [value]="p">{{ p.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<p class="cud-hint">Upload requirements will update automatically based on the type you pick.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedProofType) {
|
||||||
|
@if (selectedProofType.info) {
|
||||||
|
<div class="cud-banner">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>{{ selectedProofType.info }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="cud-section">
|
||||||
|
<span class="cud-section__label">
|
||||||
|
<fa-icon icon="hashtag"></fa-icon>
|
||||||
|
{{ selectedProofType.documentKeyLabel || 'Document number' }}
|
||||||
|
</span>
|
||||||
|
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="documentKey"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
[placeholder]="selectedProofType.documentKeyPlaceholder || 'Enter document number'"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cud-section cud-section--dates">
|
||||||
|
<div class="cud-date-field">
|
||||||
|
<span class="cud-section__label"><fa-icon icon="calendar-check"></fa-icon> Issue date</span>
|
||||||
|
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
readonly
|
||||||
|
[matDatepicker]="issuePicker"
|
||||||
|
[(ngModel)]="issueDate"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
placeholder="Optional"
|
||||||
|
(click)="issuePicker.open()"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="issuePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #issuePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="cud-date-field">
|
||||||
|
<span class="cud-section__label"><fa-icon icon="calendar-check"></fa-icon> Expiry date</span>
|
||||||
|
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
readonly
|
||||||
|
[matDatepicker]="expiryPicker"
|
||||||
|
[(ngModel)]="expiryDate"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
placeholder="Optional"
|
||||||
|
(click)="expiryPicker.open()"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="expiryPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #expiryPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cud-divider"><span>Scan</span></div>
|
||||||
|
|
||||||
|
<div class="cud-grid" [class.cud-grid--two]="sides === 2">
|
||||||
|
<div class="cud-slot">
|
||||||
|
<span class="cud-slot__label">{{ sides === 2 ? 'Front side' : 'Document' }}</span>
|
||||||
|
<label class="cud-dropzone" [class.cud-dropzone--has]="front">
|
||||||
|
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('front', $event)" />
|
||||||
|
@if (frontPreview) {
|
||||||
|
<img [src]="frontPreview" alt="Front side preview" class="cud-dropzone__img" />
|
||||||
|
} @else if (front) {
|
||||||
|
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__name">{{ front.name }}</p>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__text">Click to choose file</p>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (sides === 2) {
|
||||||
|
<div class="cud-slot">
|
||||||
|
<span class="cud-slot__label">Back side</span>
|
||||||
|
<label class="cud-dropzone" [class.cud-dropzone--has]="back">
|
||||||
|
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('back', $event)" />
|
||||||
|
@if (backPreview) {
|
||||||
|
<img [src]="backPreview" alt="Back side preview" class="cud-dropzone__img" />
|
||||||
|
} @else if (back) {
|
||||||
|
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__name">{{ back.name }}</p>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__text">Click to choose file</p>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="cud-grid">
|
||||||
|
<div class="cud-slot">
|
||||||
|
<span class="cud-slot__label">Document</span>
|
||||||
|
<label class="cud-dropzone" [class.cud-dropzone--has]="front">
|
||||||
|
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('front', $event)" />
|
||||||
|
@if (frontPreview) {
|
||||||
|
<img [src]="frontPreview" alt="Preview" class="cud-dropzone__img" />
|
||||||
|
} @else if (front) {
|
||||||
|
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__name">{{ front.name }}</p>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
|
||||||
|
<p class="cud-dropzone__text">Click to choose file</p>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions class="cud-actions" align="end">
|
||||||
|
<button mat-stroked-button type="button" (click)="cancel()">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||||
|
<fa-icon icon="upload"></fa-icon>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Header ===================== */
|
||||||
|
.cud-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid #eef1f4;
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #1a73e8, #4285f4);
|
||||||
|
box-shadow: 0 3px 10px rgb(26 115 232 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-title {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1d21;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin: -4px -4px 0 0;
|
||||||
|
color: #9aa0a6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3c4043;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Body / sections ===================== */
|
||||||
|
.cud-body.mat-mdc-dialog-content {
|
||||||
|
padding: 18px 24px !important;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
&--dates {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
.cud-date-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1a73e8;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #f0fbf6;
|
||||||
|
border: 1px solid #cfecdd;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #085041;
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #1d9e75;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 16px 0 12px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9aa0a6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: #eef1f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Front/back scan grid ===================== */
|
||||||
|
.cud-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&--two {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #5f6368;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 130px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px dashed #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
background: #fafbfc;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1a73e8;
|
||||||
|
background: #f5f9ff;
|
||||||
|
box-shadow: 0 2px 10px rgb(26 115 232 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--has {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #1d9e75;
|
||||||
|
background: #f0fbf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #9aa5b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--has &__icon {
|
||||||
|
color: #1d9e75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #085041;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Actions ===================== */
|
||||||
|
.cud-actions {
|
||||||
|
padding: 12px 24px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-top: 1px solid #eef1f4;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 560px) {
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-section--dates {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cud-grid--two {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatIconButton } from '@angular/material/button';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { ProofType } from '../../../shared/kyc-proof-types';
|
||||||
|
|
||||||
|
export interface CkycUploadDialogData {
|
||||||
|
title: string;
|
||||||
|
isProofKind: boolean;
|
||||||
|
accept: string;
|
||||||
|
proofOptions?: ProofType[];
|
||||||
|
initialProofType?: ProofType | null;
|
||||||
|
initialDocumentKey?: string;
|
||||||
|
initialIssueDate?: Date | null;
|
||||||
|
initialExpiryDate?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CkycUploadDialogResult {
|
||||||
|
proofType?: ProofType | null;
|
||||||
|
front: File;
|
||||||
|
back?: File;
|
||||||
|
/** Document number (Aadhaar/PAN/Passport number, etc.) — same field client creation collects. */
|
||||||
|
documentKey?: string;
|
||||||
|
/** 'yyyy-MM-dd', same as the rest of this app's date fields. */
|
||||||
|
issueDate?: string | null;
|
||||||
|
expiryDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A focused popup for replacing/uploading one CKYC artefact at a time — opened from each card on
|
||||||
|
* the CKYC tab instead of expanding a form inline underneath it. Collects the same details
|
||||||
|
* client creation does for an identity/address proof (document type, number, issue + expiry
|
||||||
|
* date) and shows a client-side preview of whatever image is picked before it's ever sent to the
|
||||||
|
* server; the actual upload still happens back in CkycTabComponent (this dialog only collects
|
||||||
|
* the type, number, dates and file(s), then hands them back).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-ckyc-upload-dialog',
|
||||||
|
templateUrl: './ckyc-upload-dialog.component.html',
|
||||||
|
styleUrls: ['./ckyc-upload-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
MatIconButton,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class CkycUploadDialogComponent implements OnDestroy {
|
||||||
|
private dates = inject(Dates);
|
||||||
|
dialogRef = inject<MatDialogRef<CkycUploadDialogComponent, CkycUploadDialogResult | null>>(MatDialogRef);
|
||||||
|
data = inject<CkycUploadDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
selectedProofType: ProofType | null = this.data.initialProofType || null;
|
||||||
|
documentKey = this.data.initialDocumentKey || '';
|
||||||
|
issueDate: Date | null = this.data.initialIssueDate || null;
|
||||||
|
expiryDate: Date | null = this.data.initialExpiryDate || null;
|
||||||
|
|
||||||
|
front: File | null = null;
|
||||||
|
back: File | null = null;
|
||||||
|
frontPreview: string | null = null;
|
||||||
|
backPreview: string | null = null;
|
||||||
|
|
||||||
|
get sides(): number {
|
||||||
|
return this.data.isProofKind ? (this.selectedProofType?.sides ?? 1) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSubmit(): boolean {
|
||||||
|
if (this.data.isProofKind) {
|
||||||
|
if (!this.selectedProofType) return false;
|
||||||
|
if (!this.documentKey.trim()) return false;
|
||||||
|
}
|
||||||
|
if (!this.front) return false;
|
||||||
|
if (this.sides === 2 && !this.back) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
compareProofType = (a: ProofType | null, b: ProofType | null): boolean => (a?.id ?? null) === (b?.id ?? null);
|
||||||
|
|
||||||
|
onProofTypeChange(proof: ProofType | null): void {
|
||||||
|
this.selectedProofType = proof;
|
||||||
|
this.clearSlot('front');
|
||||||
|
this.clearSlot('back');
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilePick(slot: 'front' | 'back', event: any): void {
|
||||||
|
const file = event?.target?.files?.[0];
|
||||||
|
if (event?.target) event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
this.clearSlot(slot);
|
||||||
|
const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null;
|
||||||
|
if (slot === 'front') {
|
||||||
|
this.front = file;
|
||||||
|
this.frontPreview = preview;
|
||||||
|
} else {
|
||||||
|
this.back = file;
|
||||||
|
this.backPreview = preview;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSlot(slot: 'front' | 'back'): void {
|
||||||
|
if (slot === 'front') {
|
||||||
|
if (this.frontPreview) URL.revokeObjectURL(this.frontPreview);
|
||||||
|
this.front = null;
|
||||||
|
this.frontPreview = null;
|
||||||
|
} else {
|
||||||
|
if (this.backPreview) URL.revokeObjectURL(this.backPreview);
|
||||||
|
this.back = null;
|
||||||
|
this.backPreview = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (!this.canSubmit) return;
|
||||||
|
this.dialogRef.close({
|
||||||
|
proofType: this.selectedProofType,
|
||||||
|
front: this.front!,
|
||||||
|
back: this.sides === 2 ? this.back! : undefined,
|
||||||
|
documentKey: this.data.isProofKind ? this.documentKey.trim() : undefined,
|
||||||
|
issueDate: this.issueDate ? this.dates.getDate(this.issueDate) : null,
|
||||||
|
expiryDate: this.expiryDate ? this.dates.getDate(this.expiryDate) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.dialogRef.close(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.frontPreview) URL.revokeObjectURL(this.frontPreview);
|
||||||
|
if (this.backPreview) URL.revokeObjectURL(this.backPreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,7 +229,7 @@ export class IdentitiesTabComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
const identifierData = {
|
const identifierData = {
|
||||||
documentTypeId,
|
documentTypeId,
|
||||||
status: 'Active',
|
status: 'ACTIVE',
|
||||||
documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`,
|
documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`,
|
||||||
description: kind === 'address' && section.address ? section.address : description
|
description: kind === 'address' && section.address ? section.address : description
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -840,6 +840,15 @@ export class CreateClientComponent {
|
|||||||
this.saveAddressProof(clientId);
|
this.saveAddressProof(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
|
||||||
|
* client-india-address-step's own "load existing" parsing both read back. */
|
||||||
|
private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (issueDate) parts.push(`Issue date: ${issueDate}`);
|
||||||
|
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the address-proof picked on the permanent tab as a client identifier
|
* Save the address-proof picked on the permanent tab as a client identifier
|
||||||
* + upload each selected file as a document attached to that identifier.
|
* + upload each selected file as a document attached to that identifier.
|
||||||
@@ -855,9 +864,9 @@ export class CreateClientComponent {
|
|||||||
const proofName = payload.proof?.name || 'Address Proof';
|
const proofName = payload.proof?.name || 'Address Proof';
|
||||||
const identifierData: any = {
|
const identifierData: any = {
|
||||||
documentTypeId: payload.proofId,
|
documentTypeId: payload.proofId,
|
||||||
status: 'Active',
|
status: 'ACTIVE',
|
||||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||||
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
|
description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||||
@@ -904,7 +913,7 @@ export class CreateClientComponent {
|
|||||||
}
|
}
|
||||||
const identifierData: any = {
|
const identifierData: any = {
|
||||||
documentTypeId,
|
documentTypeId,
|
||||||
status: 'Active',
|
status: 'ACTIVE',
|
||||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||||
description: 'KYC document'
|
description: 'KYC document'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1035,6 +1035,15 @@ export class EditClientComponent {
|
|||||||
this.saveAddressProof(clientId);
|
this.saveAddressProof(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
|
||||||
|
* client-india-address-step's own "load existing" parsing both read back. */
|
||||||
|
private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (issueDate) parts.push(`Issue date: ${issueDate}`);
|
||||||
|
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the address-proof picked on the permanent tab as a client identifier
|
* Save the address-proof picked on the permanent tab as a client identifier
|
||||||
* + upload each selected file as a document attached to that identifier.
|
* + upload each selected file as a document attached to that identifier.
|
||||||
@@ -1050,9 +1059,9 @@ export class EditClientComponent {
|
|||||||
const proofName = payload.proof?.name || 'Address Proof';
|
const proofName = payload.proof?.name || 'Address Proof';
|
||||||
const identifierData: any = {
|
const identifierData: any = {
|
||||||
documentTypeId: payload.proofId,
|
documentTypeId: payload.proofId,
|
||||||
status: 'Active',
|
status: 'ACTIVE',
|
||||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||||
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
|
description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||||
@@ -1105,7 +1114,7 @@ export class EditClientComponent {
|
|||||||
const existingId = this.clientKycStep?.existingKycIdentifierId;
|
const existingId = this.clientKycStep?.existingKycIdentifierId;
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||||
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
|
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' };
|
||||||
if (documentTypeId != null) {
|
if (documentTypeId != null) {
|
||||||
update.documentTypeId = documentTypeId;
|
update.documentTypeId = documentTypeId;
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1137,7 @@ export class EditClientComponent {
|
|||||||
}
|
}
|
||||||
const identifierData: any = {
|
const identifierData: any = {
|
||||||
documentTypeId,
|
documentTypeId,
|
||||||
status: 'Active',
|
status: 'ACTIVE',
|
||||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||||
description: 'KYC document'
|
description: 'KYC document'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260911',
|
'version': '260914',
|
||||||
'hash': '7afa9c7a'
|
'hash': 'ab58d7f7'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user