diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html index c1922f22c..4daf63941 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html @@ -221,6 +221,22 @@ } + + Expiry date (optional) + event_busy + + + +
@@ -238,14 +254,18 @@ @if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
- @if (addrMergedPreview) { - Saved address proof + @if (existingAddrFrontSlotPreview) { + Saved address proof } @else { description }
- {{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }} + {{ existingAddrFrontSlotName }}
+ @if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { +
+ @if (existing.ADDRESS_PROOF.previewUrl) { + Address proof scan - front + } + @if (existing.ADDRESS_PROOF.previewUrlBack) { + Address proof scan - back + } +
+ }
{{ existing.ADDRESS_PROOF.name }} @@ -217,78 +235,51 @@ }
+ @if (cardDate('ADDRESS_PROOF', 'issue') || cardDate('ADDRESS_PROOF', 'expiry')) { +
+ @if (cardDate('ADDRESS_PROOF', 'issue')) { + Issued {{ cardDate('ADDRESS_PROOF', 'issue') }} + } + @if (cardDate('ADDRESS_PROOF', 'expiry')) { + Expires {{ cardDate('ADDRESS_PROOF', 'expiry') }} + } +
+ } +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

-
- - - - - + @if (uploading['ADDRESS_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['ADDRESS_PROOF']) { +
+ error_outline + {{ uploadErrors['ADDRESS_PROOF'] }}
- - @if (uploadErrors['ADDRESS_PROOF']) { -
- error_outline - {{ uploadErrors['ADDRESS_PROOF'] }} -
- } - @if (uploadResponses['ADDRESS_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].processingStatus }} -

-
- } } @@ -310,6 +301,24 @@ from {{ existing.PHOTO_PROOF.source }}
+ @if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { +
+ @if (existing.PHOTO_PROOF.previewUrl) { + ID proof scan - front + } + @if (existing.PHOTO_PROOF.previewUrlBack) { + ID proof scan - back + } +
+ }
{{ existing.PHOTO_PROOF.name }} @@ -322,78 +331,49 @@ }
+ @if (cardDate('PHOTO_PROOF', 'issue') || cardDate('PHOTO_PROOF', 'expiry')) { +
+ @if (cardDate('PHOTO_PROOF', 'issue')) { + Issued {{ cardDate('PHOTO_PROOF', 'issue') }} + } + @if (cardDate('PHOTO_PROOF', 'expiry')) { + Expires {{ cardDate('PHOTO_PROOF', 'expiry') }} + } +
+ } + + } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF

-
- - - - - + @if (uploading['PHOTO_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['PHOTO_PROOF']) { +
+ error_outline + {{ uploadErrors['PHOTO_PROOF'] }}
- - @if (uploadErrors['PHOTO_PROOF']) { -
- error_outline - {{ uploadErrors['PHOTO_PROOF'] }} -
- } - @if (uploadResponses['PHOTO_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['PHOTO_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['PHOTO_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].processingStatus }} -

-
- } } @@ -422,74 +402,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_IMAGE')" + (click)="openUploadDialog('CLIENT_IMAGE')" > - - {{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

-
- - - - - + @if (uploading['CLIENT_IMAGE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_IMAGE']) { +
+ error_outline + {{ uploadErrors['CLIENT_IMAGE'] }}
- - @if (uploadErrors['CLIENT_IMAGE']) { -
- error_outline - {{ uploadErrors['CLIENT_IMAGE'] }} -
- } - @if (uploadResponses['CLIENT_IMAGE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].processingStatus }} -

-
- } } @@ -526,75 +465,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_SIGNATURE')" + (click)="openUploadDialog('CLIENT_SIGNATURE')" > - - {{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) { -

{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG

-
- - - - - + @if (uploading['CLIENT_SIGNATURE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_SIGNATURE']) { +
+ error_outline + {{ uploadErrors['CLIENT_SIGNATURE'] }}
- - @if (uploadErrors['CLIENT_SIGNATURE']) { -
- error_outline - {{ uploadErrors['CLIENT_SIGNATURE'] }} -
- } - @if (uploadResponses['CLIENT_SIGNATURE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_SIGNATURE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }} -

-
- } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss index 66d527c48..602d2dd3e 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -553,15 +553,21 @@ margin: 0 0 12px; } -.ckyc-file-row { +.ckyc-missing { + margin-top: 14px; display: flex; + flex-direction: column; + gap: 10px; align-items: flex-start; - gap: 8px; - margin-bottom: 12px; } -.ckyc-file-name-field { - flex: 1; +.ckyc-uploading { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + font-size: 12px; + color: #5f6368; } .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) { .ckyc-upload-grid { grid-template-columns: 1fr; @@ -610,14 +603,6 @@ .ckyc-upload-grid { grid-template-columns: 1fr; } - - .ckyc-file-row { - flex-direction: column; - } - - .ckyc-file-name-field { - width: 100%; - } } /* ===================== Already-available banners ===================== */ @@ -731,6 +716,34 @@ 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 { width: 100px; height: 100px; diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index adcd3d486..0acbbf2d8 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -9,27 +9,38 @@ /** 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, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; import { AlertService } from 'app/core/alert/alert.service'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatDialog } from '@angular/material/dialog'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; 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 { present: boolean; name?: string; source?: string; previewUrl?: string; + previewUrlBack?: string; documentId?: string; + documentTypeId?: 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; - expanded?: boolean; } +type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; +type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; + @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', @@ -46,6 +57,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private clientsService = inject(ClientsService); private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private dates = inject(Dates); private destroy$ = new Subject(); @@ -80,16 +93,41 @@ export class CkycTabComponent implements OnInit, OnDestroy { } /** Pre-existing artefacts detected from client data */ - existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { + existing: Record = { ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { 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 = { + ADDRESS_PROOF: null, + PHOTO_PROOF: null + }; + + /** Front/back files staged for the two proof cards; other cards keep a single file each */ + docFiles: Record = { + 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 */ - addressProofFile: File | null = null; - photoProofFile: File | null = null; clientImageFile: File | null = null; clientSignatureFile: File | null = null; @@ -101,9 +139,6 @@ export class CkycTabComponent implements OnInit, OnDestroy { CLIENT_SIGNATURE: false }; - /** Per-document upload response */ - uploadResponses: { [key: string]: any } = {}; - /** Per-document error messages */ uploadErrors: { [key: string]: string | 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 + this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p)); + this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p)); const idProofIds = new Set((proofTypes?.idProofs || []).map((p: any) => String(p.id))); const addressProofIds = new Set((proofTypes?.addressProofs || []).map((p: any) => String(p.id))); @@ -287,21 +324,77 @@ export class CkycTabComponent implements OnInit, OnDestroy { name: proofName, documentKey: isSyntheticKey ? '' : rawKey, documentId: identity?.id, + documentTypeId: docTypeId, + description: identity?.description || '', fileCount: (identity?.documents || []).length }; if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) { 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) { 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]; - if (entry) entry.expanded = !entry.expanded; + /** + * The scanned ID/address-proof card only showed its file name and key — never the scan + * 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 " — 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 { @@ -309,33 +402,8 @@ 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; - - 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 { switch (documentType) { - case 'ADDRESS_PROOF': - return this.addressProofFile; - case 'PHOTO_PROOF': - return this.photoProofFile; case 'CLIENT_IMAGE': return this.clientImageFile; case 'CLIENT_SIGNATURE': @@ -345,38 +413,278 @@ export class CkycTabComponent implements OnInit, OnDestroy { } } + private static readonly CARD_TITLES: Record = { + 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 { + if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') { + this.uploadProofDocument(documentType); + return; + } + const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; + const key = documentType as keyof typeof this.existing; - const formData = new FormData(); - formData.append('file', file); - formData.append('documentType', documentType); + let request$: Observable; + switch (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 - .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.'; + request$.pipe(takeUntil(this.destroy$)).subscribe({ + next: () => { + this.uploading[documentType] = false; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(documentType); + }, + error: (err: any) => { + this.uploading[documentType] = false; + this.uploadErrors[documentType] = this.extractErrorMessage(err); + } + }); + } + + /** + * Proof cards work like the create-client KYC step: pick a document type first (its `sides` + * decides whether one image or a front+back pair is required), then upload. Reuses the same + * identifier the Identities tab manages — updates its documentTypeId if the picked type + * changed, or creates a fresh identifier when none exists yet, so this card never depends on + * 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 = !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[] = []; + + 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 { @@ -385,6 +693,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { // Release object URLs Object.values(this.existing).forEach((e) => { if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); + if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); }); } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html new file mode 100644 index 000000000..cae9e00ff --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html @@ -0,0 +1,167 @@ + + +
+
+ +
+
+

{{ data.title }}

+

Fill in the document details, then attach the scan.

+
+ +
+ + + @if (data.isProofKind) { +
+ + + + @for (p of data.proofOptions; track p.id) { + {{ p.name }} + } + + +

Upload requirements will update automatically based on the type you pick.

+
+ + @if (selectedProofType) { + @if (selectedProofType.info) { +
+ + {{ selectedProofType.info }} +
+ } + +
+ + + + +
+ + + +
Scan
+ +
+
+ {{ sides === 2 ? 'Front side' : 'Document' }} + +
+ + @if (sides === 2) { +
+ Back side + +
+ } +
+ } + } @else { +
+
+ Document + +
+
+ } +
+ + + + + diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss new file mode 100644 index 000000000..a6833e451 --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss @@ -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; + } +} diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts new file mode 100644 index 000000000..3fe969bce --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts @@ -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); + data = inject(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); + } +} diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts index fc9d25486..daaafe57c 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts @@ -229,7 +229,7 @@ export class IdentitiesTabComponent implements OnDestroy { } const identifierData = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`, description: kind === 'address' && section.address ? section.address : description }; diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 6218ea5ac..ccd05c057 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -840,6 +840,15 @@ export class CreateClientComponent { 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 * + 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 identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', 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({ @@ -904,7 +913,7 @@ export class CreateClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 0f300376a..b2a07e089 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -1035,6 +1035,15 @@ export class EditClientComponent { 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 * + 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 identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', 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({ @@ -1105,7 +1114,7 @@ export class EditClientComponent { const existingId = this.clientKycStep?.existingKycIdentifierId; if (existingId) { 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) { update.documentTypeId = documentTypeId; } @@ -1128,7 +1137,7 @@ export class EditClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 83bb12841..e4d9bc1c4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260911', - 'hash': '7afa9c7a' + 'version': '260914', + 'hash': 'ab58d7f7' }, 'allow_switching_backend_instance': true };