feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities
This commit is contained in:
@@ -187,6 +187,124 @@
|
||||
}
|
||||
|
||||
<div class="ckyc-upload-grid">
|
||||
<!-- ID Proof Document -->
|
||||
<mat-card class="ckyc-upload-card">
|
||||
<mat-card-content>
|
||||
<div class="ckyc-upload-header">
|
||||
<mat-icon class="ckyc-upload-icon">badge</mat-icon>
|
||||
<span>{{ 'labels.inputs.ID Proof Document' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (existing.ID_PROOF.present) {
|
||||
<div class="ckyc-existing">
|
||||
<div class="ckyc-existing__head">
|
||||
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.ID_PROOF.source }}</span>
|
||||
@if (existing.ID_PROOF.previewUnavailable) {
|
||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.ID_PROOF.previewUrl && isImage('ID_PROOF')) {
|
||||
<img [src]="existing.ID_PROOF.previewUrl" alt="ID proof" class="ckyc-existing__thumb" />
|
||||
}
|
||||
@if (existing.ID_PROOF.previewUrl) {
|
||||
<button mat-stroked-button type="button" class="ckyc-existing__view" (click)="openDocument('ID_PROOF')">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
View document
|
||||
</button>
|
||||
}
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="file-alt"></fa-icon>
|
||||
<span>{{ existing.ID_PROOF.name }}</span>
|
||||
@if (existing.ID_PROOF.documentKey) {
|
||||
<span class="ckyc-existing__key">{{ existing.ID_PROOF.documentKey }}</span>
|
||||
}
|
||||
@if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) {
|
||||
<span class="ckyc-existing__count">
|
||||
{{ existing.ID_PROOF.fileCount }}
|
||||
{{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__replace"
|
||||
(click)="toggleReplace('ID_PROOF')"
|
||||
>
|
||||
<fa-icon [icon]="existing.ID_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
|
||||
{{ existing.ID_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) {
|
||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
|
||||
<div class="ckyc-file-row">
|
||||
<input
|
||||
type="file"
|
||||
#idProofInput
|
||||
accept=".png,.jpeg,.jpg,.pdf"
|
||||
(change)="onFileSelect($event, 'ID_PROOF')"
|
||||
[style.display]="'none'"
|
||||
/>
|
||||
<mat-form-field appearance="outline" class="ckyc-file-name-field">
|
||||
<input
|
||||
matInput
|
||||
[readonly]="true"
|
||||
[value]="idProofFile?.name || ''"
|
||||
[placeholder]="'labels.text.NoFileSelected' | translate"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button mat-stroked-button type="button" (click)="idProofInput.click()">
|
||||
<fa-icon icon="folder-open" size="lg"></fa-icon>
|
||||
{{ 'labels.buttons.Browse' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!idProofFile || uploading['ID_PROOF']"
|
||||
(click)="uploadDocument('ID_PROOF')"
|
||||
>
|
||||
@if (uploading['ID_PROOF']) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
{{ 'labels.buttons.Upload' | translate }}
|
||||
}
|
||||
</button>
|
||||
@if (uploadErrors['ID_PROOF']) {
|
||||
<div class="ckyc-upload-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ uploadErrors['ID_PROOF'] }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (uploadResponses['ID_PROOF']) {
|
||||
<div class="ckyc-upload-response">
|
||||
<p>
|
||||
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ID_PROOF'].status }}
|
||||
</p>
|
||||
<p>
|
||||
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ID_PROOF'].requestId }}
|
||||
</p>
|
||||
<p>
|
||||
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
|
||||
{{ uploadResponses['ID_PROOF'].acknowledgementNumber }}
|
||||
</p>
|
||||
<p>
|
||||
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
|
||||
{{ uploadResponses['ID_PROOF'].processingStatus }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Address Proof Document -->
|
||||
<mat-card class="ckyc-upload-card">
|
||||
<mat-card-content>
|
||||
@@ -202,8 +320,25 @@
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
||||
@if (existing.ADDRESS_PROOF.previewUnavailable) {
|
||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) {
|
||||
<img [src]="existing.ADDRESS_PROOF.previewUrl" alt="Address proof" class="ckyc-existing__thumb" />
|
||||
}
|
||||
@if (existing.ADDRESS_PROOF.previewUrl) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__view"
|
||||
(click)="openDocument('ADDRESS_PROOF')"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
View document
|
||||
</button>
|
||||
}
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="file-alt"></fa-icon>
|
||||
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
||||
@@ -308,8 +443,25 @@
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
||||
@if (existing.PHOTO_PROOF.previewUnavailable) {
|
||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) {
|
||||
<img [src]="existing.PHOTO_PROOF.previewUrl" alt="Photo proof" class="ckyc-existing__thumb" />
|
||||
}
|
||||
@if (existing.PHOTO_PROOF.previewUrl) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__view"
|
||||
(click)="openDocument('PHOTO_PROOF')"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
View document
|
||||
</button>
|
||||
}
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="id-card"></fa-icon>
|
||||
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
||||
@@ -413,10 +565,22 @@
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
|
||||
@if (existing.CLIENT_IMAGE.previewUnavailable) {
|
||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.CLIENT_IMAGE.previewUrl) {
|
||||
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__view"
|
||||
(click)="openDocument('CLIENT_IMAGE')"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
View document
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
@@ -509,6 +673,9 @@
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
|
||||
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
|
||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.CLIENT_SIGNATURE.previewUrl) {
|
||||
@@ -518,6 +685,17 @@
|
||||
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
|
||||
/>
|
||||
}
|
||||
@if (existing.CLIENT_SIGNATURE.previewUrl) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__view"
|
||||
(click)="openDocument('CLIENT_SIGNATURE')"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
View document
|
||||
</button>
|
||||
}
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
|
||||
|
||||
@@ -638,6 +638,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
|
||||
the only way to inspect a PDF - which cannot render as an inline <img>. */
|
||||
.ckyc-existing__view {
|
||||
align-self: flex-start;
|
||||
color: #1d6fd0;
|
||||
border-color: #bcd8f5;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: #eef6ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown when a document row exists but its file could not be fetched from the content store,
|
||||
so "Already available" is not silently misleading. */
|
||||
.ckyc-existing__warn {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fdecec;
|
||||
border: 1px solid #f3c2c2;
|
||||
color: #a12b2b;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ckyc-existing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
|
||||
import { Subject, takeUntil, catchError, of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
@@ -28,8 +28,60 @@ interface ExistingArtefact {
|
||||
documentKey?: string;
|
||||
fileCount?: number;
|
||||
expanded?: boolean;
|
||||
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
|
||||
previewUnavailable?: boolean;
|
||||
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart `name` / `description` sent for each CKYC artefact type.
|
||||
*
|
||||
* The platform has no CKYC-specific document API; these are stored through the generic
|
||||
* DocumentApiResource, whose contract carries no document-type field. The artefact type therefore
|
||||
* travels in `name`, which is the same mechanism the client signature already uses - so
|
||||
* `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that
|
||||
* loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would
|
||||
* orphan every previously uploaded signature.
|
||||
*/
|
||||
export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
||||
|
||||
const CKYC_DOCUMENT_META: Record<CkycDocumentType, { name: string; description: string }> = {
|
||||
ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' },
|
||||
ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' },
|
||||
PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' },
|
||||
CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' },
|
||||
CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' }
|
||||
};
|
||||
|
||||
const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[];
|
||||
|
||||
/**
|
||||
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
|
||||
* is not rejected server-side:
|
||||
* - 5 MB matches `spring.servlet.multipart.max-file-size`
|
||||
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
|
||||
* with what these cards can actually render.
|
||||
* `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check
|
||||
* lives in onFileSelect().
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'png',
|
||||
'jpeg',
|
||||
'jpg'
|
||||
];
|
||||
const DOCUMENT_EXTENSIONS = [
|
||||
...IMAGE_EXTENSIONS,
|
||||
'pdf'
|
||||
];
|
||||
|
||||
/** Types whose card renders an inline <img> preview, so a PDF would not display. */
|
||||
const IMAGE_ONLY_TYPES: CkycDocumentType[] = [
|
||||
'CLIENT_IMAGE',
|
||||
'CLIENT_SIGNATURE'
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-ckyc-tab',
|
||||
templateUrl: './ckyc-tab.component.html',
|
||||
@@ -79,8 +131,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
return this.panVerified && this.aadhaarVerified;
|
||||
}
|
||||
|
||||
/** Pre-existing artefacts detected from client data */
|
||||
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
|
||||
/** CKYC artefacts already uploaded for this client, keyed by artefact type. */
|
||||
existing: Record<CkycDocumentType, ExistingArtefact> = {
|
||||
ID_PROOF: { present: false },
|
||||
ADDRESS_PROOF: { present: false },
|
||||
PHOTO_PROOF: { present: false },
|
||||
CLIENT_IMAGE: { present: false },
|
||||
@@ -88,6 +141,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
/** File references */
|
||||
idProofFile: File | null = null;
|
||||
addressProofFile: File | null = null;
|
||||
photoProofFile: File | null = null;
|
||||
clientImageFile: File | null = null;
|
||||
@@ -95,6 +149,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Per-document upload state */
|
||||
uploading: { [key: string]: boolean } = {
|
||||
ID_PROOF: false,
|
||||
ADDRESS_PROOF: false,
|
||||
PHOTO_PROOF: false,
|
||||
CLIENT_IMAGE: false,
|
||||
@@ -106,6 +161,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Per-document error messages */
|
||||
uploadErrors: { [key: string]: string | null } = {
|
||||
ID_PROOF: null,
|
||||
ADDRESS_PROOF: null,
|
||||
PHOTO_PROOF: null,
|
||||
CLIENT_IMAGE: null,
|
||||
@@ -207,98 +263,110 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo (profile image), signature (client document),
|
||||
* ID proof and address proof (client identifiers) so already-uploaded
|
||||
* artefacts are shown here instead of forcing re-upload.
|
||||
* Loads every CKYC artefact from the client's documents (`m_document`), which is the single
|
||||
* source of truth for this screen: each card matches the `name` its own upload writes, so what
|
||||
* the checker sees is exactly what CKYC stored.
|
||||
*
|
||||
* Deliberately does NOT read the profile image or client identifiers any more. Those belong to
|
||||
* client registration and the Identities section; reading them here showed artefacts this screen
|
||||
* had never uploaded, and hid the ones it had. Those other screens are untouched.
|
||||
*
|
||||
* A client can accumulate several rows for one artefact (older uploads pre-date replacement, and
|
||||
* the signature is also written by client registration), so the newest row - highest `id` - wins.
|
||||
*/
|
||||
private loadExistingArtefacts(): void {
|
||||
this.checkingExisting = true;
|
||||
|
||||
forkJoin({
|
||||
photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
|
||||
documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
|
||||
identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
|
||||
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(({ photo, documents, identifiers, proofTypes }) => {
|
||||
this.clientsService
|
||||
.getClientDocuments(this.clientId)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
.subscribe((documents: any) => {
|
||||
this.checkingExisting = false;
|
||||
|
||||
// 1. Photo / image of client — from m_client.image_id
|
||||
if (photo && typeof photo === 'string' && photo.length > 0) {
|
||||
this.existing.CLIENT_IMAGE = {
|
||||
present: true,
|
||||
source: 'Client registration',
|
||||
previewUrl: photo,
|
||||
name: 'Profile photo'
|
||||
};
|
||||
}
|
||||
const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []);
|
||||
|
||||
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
||||
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
||||
// so pick the highest id (latest) rather than the first unordered match.
|
||||
const signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.existing.CLIENT_SIGNATURE = {
|
||||
present: true,
|
||||
source: 'Client registration',
|
||||
name: signatureDoc.fileName || 'Client signature',
|
||||
documentId: signatureDoc.id
|
||||
};
|
||||
// Fetch blob for preview
|
||||
this.clientsService
|
||||
.getClientSignatureImage(this.clientId, signatureDoc.id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob) {
|
||||
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
for (const type of CKYC_DOCUMENT_TYPES) {
|
||||
const expectedName = CKYC_DOCUMENT_META[type].name;
|
||||
|
||||
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
||||
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 doc = list
|
||||
.filter((d: any) => d?.name === expectedName)
|
||||
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0];
|
||||
|
||||
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
|
||||
|
||||
for (const identity of idList) {
|
||||
const docTypeId = String(identity?.documentType?.id ?? '');
|
||||
const isActive =
|
||||
!identity?.status ||
|
||||
identity?.status?.code === 'clientIdentifierStatusType.active' ||
|
||||
identity?.status === 'clientIdentifierStatusType.active' ||
|
||||
identity?.status === 'Active';
|
||||
if (!isActive) continue;
|
||||
|
||||
const proofName = identity?.documentType?.name || 'Identifier';
|
||||
// Address proof documentKey is auto-generated as "<ProofName> - <clientId>"
|
||||
// in identities-tab; hide it when it's just that synthetic key.
|
||||
const rawKey = identity?.documentKey || '';
|
||||
const isSyntheticKey = rawKey.startsWith(`${proofName} - `);
|
||||
const common = {
|
||||
present: true,
|
||||
source: 'Identities section',
|
||||
name: proofName,
|
||||
documentKey: isSyntheticKey ? '' : rawKey,
|
||||
documentId: identity?.id,
|
||||
fileCount: (identity?.documents || []).length
|
||||
};
|
||||
|
||||
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
|
||||
this.existing.PHOTO_PROOF = common;
|
||||
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
|
||||
this.existing.ADDRESS_PROOF = common;
|
||||
if (!doc) {
|
||||
// No upload yet for this artefact - leave the card in its empty state.
|
||||
this.existing[type] = { present: false };
|
||||
continue;
|
||||
}
|
||||
|
||||
this.existing[type] = {
|
||||
present: true,
|
||||
source: 'CKYC upload',
|
||||
name: doc.fileName || expectedName,
|
||||
documentId: doc.id,
|
||||
mimeType: doc.type
|
||||
};
|
||||
|
||||
this.loadPreview(type, doc.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the stored file so the card can show a thumbnail. A failure here is reported on the
|
||||
* card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in
|
||||
* the content store, and silently showing "Already available" with no image hides that.
|
||||
*/
|
||||
private loadPreview(type: CkycDocumentType, documentId: string | number): void {
|
||||
this.clientsService
|
||||
// silent: a missing stored file returns 500, and the card below already reports that. Without
|
||||
// this the global error interceptor pops an "Internal Server Error" modal on every page load
|
||||
// and after every upload, for a failure the user cannot act on.
|
||||
.downloadClientDocument(this.clientId, String(documentId), true)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob && blob.size > 0) {
|
||||
this.existing[type].previewUrl = URL.createObjectURL(blob);
|
||||
} else {
|
||||
// A 200 with an empty body still means there is nothing to show. Without this the card
|
||||
// would sit there claiming "Already available" with no thumbnail and no explanation.
|
||||
this.existing[type].previewUnavailable = true;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.existing[type].previewUnavailable = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
|
||||
* back to the "View document" button alone rather than rendering a broken image.
|
||||
*/
|
||||
isImage(documentType: CkycDocumentType): boolean {
|
||||
const entry = this.existing[documentType];
|
||||
if (entry?.mimeType) {
|
||||
return entry.mimeType.startsWith('image/');
|
||||
}
|
||||
// Older rows can have no stored MIME type - fall back to the file extension.
|
||||
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
|
||||
* thumbnail, so it works for PDFs too and costs no extra request.
|
||||
*/
|
||||
openDocument(documentType: CkycDocumentType): void {
|
||||
const url = this.existing[documentType]?.previewUrl;
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
toggleReplace(documentType: string): void {
|
||||
const entry = (this.existing as any)[documentType];
|
||||
if (entry) entry.expanded = !entry.expanded;
|
||||
@@ -309,11 +377,38 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
||||
}
|
||||
|
||||
onFileSelect(event: any, documentType: string): void {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
/** Extensions a given card can both store and render. */
|
||||
private allowedExtensions(documentType: string): string[] {
|
||||
return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
|
||||
* so the user gets a clear message instead of a raw server error. Returns null when the file is
|
||||
* acceptable.
|
||||
*/
|
||||
private validationError(file: File, documentType: string): string | null {
|
||||
const allowed = this.allowedExtensions(documentType);
|
||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
|
||||
if (!allowed.includes(extension)) {
|
||||
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
||||
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return 'The selected file is empty.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setFile(documentType: string, file: File | null): void {
|
||||
switch (documentType) {
|
||||
case 'ID_PROOF':
|
||||
this.idProofFile = file;
|
||||
break;
|
||||
case 'ADDRESS_PROOF':
|
||||
this.addressProofFile = file;
|
||||
break;
|
||||
@@ -327,11 +422,28 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.clientSignatureFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelect(event: any, documentType: string): void {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const error = this.validationError(file, documentType);
|
||||
if (error) {
|
||||
this.setFile(documentType, null);
|
||||
this.uploadErrors[documentType] = error;
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFile(documentType, file);
|
||||
this.uploadErrors[documentType] = null;
|
||||
}
|
||||
|
||||
getFile(documentType: string): File | null {
|
||||
switch (documentType) {
|
||||
case 'ID_PROOF':
|
||||
return this.idProofFile;
|
||||
case 'ADDRESS_PROOF':
|
||||
return this.addressProofFile;
|
||||
case 'PHOTO_PROOF':
|
||||
@@ -345,38 +457,64 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
uploadDocument(documentType: string): void {
|
||||
uploadDocument(documentType: CkycDocumentType): void {
|
||||
const file = this.getFile(documentType);
|
||||
if (!file) return;
|
||||
|
||||
this.uploading[documentType] = true;
|
||||
this.uploadErrors[documentType] = null;
|
||||
|
||||
// DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and
|
||||
// `description` (file + description mandatory). There is no document-type field in that
|
||||
// contract, so the artefact type is carried in `name` - which is also the key
|
||||
// loadExistingArtefacts() matches on when reading the document back.
|
||||
const meta = CKYC_DOCUMENT_META[documentType];
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('documentType', documentType);
|
||||
formData.append('name', meta.name);
|
||||
formData.append('description', meta.description);
|
||||
|
||||
this.clientsService
|
||||
.uploadCkycDocument(this.clientId, formData)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadResponses[documentType] = response;
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message: response?.message || 'Document uploaded successfully.'
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadErrors[documentType] =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to upload document.';
|
||||
}
|
||||
});
|
||||
// Replace in place when this artefact already has a usable document, so a re-upload updates the
|
||||
// existing row (same document id, same content-store location) instead of stacking up another
|
||||
// copy. Falls back to create for the first upload of an artefact.
|
||||
//
|
||||
// Deliberately create instead of replace when the stored file is missing: the backend's update
|
||||
// path writes the new content relative to the row's existing `location` and never calls
|
||||
// setLocation(), so replacing a row whose file is already gone writes the upload to a fresh
|
||||
// folder while the row keeps pointing at the missing one - the artefact would stay broken and
|
||||
// the user would get a success message. Creating a new row repairs the card instead.
|
||||
const entry = this.existing[documentType];
|
||||
const replacing = !!entry?.documentId && !entry?.previewUnavailable;
|
||||
const existingId = entry?.documentId;
|
||||
|
||||
const request$ = replacing
|
||||
? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData)
|
||||
: this.clientsService.uploadCkycDocument(this.clientId, formData);
|
||||
|
||||
request$.pipe(takeUntil(this.destroy$)).subscribe({
|
||||
next: (response: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadResponses[documentType] = response;
|
||||
this.setFile(documentType, null);
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message:
|
||||
response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.')
|
||||
});
|
||||
// Re-read from m_document so the card reflects what was actually stored, rather than
|
||||
// assuming the write succeeded exactly as sent.
|
||||
this.loadExistingArtefacts();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.uploading[documentType] = false;
|
||||
this.uploadErrors[documentType] =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to upload document.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@@ -569,8 +569,19 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/documents`);
|
||||
}
|
||||
|
||||
downloadClientDocument(parentEntityId: string, documentId: string) {
|
||||
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
|
||||
/**
|
||||
* Fetches a client document's stored file.
|
||||
*
|
||||
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
|
||||
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
|
||||
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
|
||||
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
|
||||
*/
|
||||
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
|
||||
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
|
||||
responseType: 'blob',
|
||||
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
|
||||
});
|
||||
}
|
||||
|
||||
uploadClientDocument(clientId: string, documentData: any) {
|
||||
@@ -992,12 +1003,28 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
|
||||
}
|
||||
|
||||
getClientCkyc(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/ckyc`);
|
||||
/**
|
||||
* Uploads a single CKYC artefact against the client, using the platform's generic document
|
||||
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
|
||||
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
|
||||
* was never implemented and returned 404.
|
||||
*
|
||||
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
|
||||
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
|
||||
*/
|
||||
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/documents`, formData);
|
||||
}
|
||||
|
||||
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
|
||||
/**
|
||||
* Replaces the content of an existing client document in place (DocumentApiResource PUT
|
||||
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
|
||||
*
|
||||
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
|
||||
* rather than adding another row. Takes the same multipart body as the create call.
|
||||
*/
|
||||
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
|
||||
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user