/** * 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, OnInit, inject } from '@angular/core'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions, MatDialogClose } from '@angular/material/dialog'; import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component'; import { CaptureImageDialogComponent } from '../capture-image-dialog/capture-image-dialog.component'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component'; import { MatIconButton } from '@angular/material/button'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ClientsService } from '../../../clients.service'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types'; import { dataUrlToFile } from '../../../../shared/utils/data-url.util'; type SectionKey = 'id' | 'address'; @Component({ selector: 'mifosx-upload-document-dialog', templateUrl: './upload-document-dialog.component.html', styleUrls: ['./upload-document-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatDialogTitle, FileUploadComponent, MatDialogActions, MatDialogClose, MatIconButton ] }) export class UploadDocumentDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); private formBuilder = inject(UntypedFormBuilder); private clientsService = inject(ClientsService); private dialog = inject(MatDialog); data = inject(MAT_DIALOG_DATA); uploadDocumentForm: UntypedFormGroup; uploadDocumentData: any = []; documentIdentifier = false; entityType: string; /** Prefilled values from client registration (identifier mode) */ clientName = ''; clientAddress = ''; /** Identifier-mode state */ idProofs: ProofType[] = []; addressProofs: ProofType[] = []; addressGroups: { label: string; items: ProofType[] }[] = []; loadingProofs = false; selectedIdProof: ProofType | null = null; selectedAddrProof: ProofType | null = null; idFiles: { front: File | null; back: File | null } = { front: null, back: null }; addrFiles: { front: File | null; back: File | null } = { front: null, back: null }; /** Merged front+back PNG previews (data URLs) and File objects */ idMergedFile: File | null = null; idMergedPreview: string | null = null; addrMergedFile: File | null = null; addrMergedPreview: string | null = null; /** Non-identifier (generic) mode only — set after a webcam capture, since * `FileUploadComponent` only reflects files picked through its own native input and has * no input for one supplied programmatically. Cleared if the user then picks a file via * Browse instead, so only one confirmation shows at a time. */ capturedFileName: string | null = null; constructor() { const data = this.data; this.documentIdentifier = !!data?.documentIdentifier; this.entityType = data?.entityType; this.clientName = data?.clientName || ''; this.clientAddress = data?.clientAddress || ''; } ngOnInit() { this.createUploadDocumentForm(); if (this.documentIdentifier) { this.loadProofTypes(); } } createUploadDocumentForm() { if (this.documentIdentifier) { this.uploadDocumentForm = this.formBuilder.group({ id: this.formBuilder.group({ proofId: [''], documentKey: [''] }), address: this.formBuilder.group({ proofId: [''], issueDate: [''] }), description: [''] }); } else { this.uploadDocumentForm = this.formBuilder.group({ fileName: [ '', Validators.required ], description: [''], file: [''] }); } } private loadProofTypes(): void { this.loadingProofs = true; this.clientsService.getKycProofTypes().subscribe({ next: (res: any) => { const activeFilter = (p: any) => p?.active !== false; this.idProofs = (res?.idProofs || []).filter(activeFilter).map(normalizeProofType); this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType); this.addressGroups = groupProofsByCategory(this.addressProofs); this.loadingProofs = false; }, error: () => { this.loadingProofs = false; } }); } onIdProofChange(proofId: any): void { this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null; this.idFiles = { front: null, back: null }; this.idMergedFile = null; this.idMergedPreview = null; } selectAddrProof(proof: ProofType): void { this.selectedAddrProof = proof; this.uploadDocumentForm.get('address.proofId').setValue(proof.id); this.addrFiles = { front: null, back: null }; this.addrMergedFile = null; this.addrMergedPreview = null; } isAddrProofSelected(proof: ProofType): boolean { return this.selectedAddrProof?.id === proof.id; } onFilePick(section: SectionKey, slot: 'front' | 'back', event: any): void { const file = event?.target?.files?.[0]; // reset the input so picking the same file again still triggers change if (event?.target) event.target.value = ''; if (!file) return; // Non-image (e.g. PDF) — bypass cropping if (!file.type.startsWith('image/')) { this.setSlotFile(section, slot, file); this.maybeMerge(section); return; } const ref = this.dialog.open(ImageCropperDialogComponent, { width: '720px', maxWidth: '95vw', data: { file, title: `Crop ${slot} side` } }); ref.afterClosed().subscribe((cropped: File | null) => { if (!cropped) return; this.setSlotFile(section, slot, cropped); this.maybeMerge(section); }); } reCrop(section: SectionKey, slot: 'front' | 'back'): void { const current = section === 'id' ? this.idFiles[slot] : this.addrFiles[slot]; if (!current || !current.type.startsWith('image/')) return; const ref = this.dialog.open(ImageCropperDialogComponent, { width: '720px', maxWidth: '95vw', data: { file: current, title: `Re-crop ${slot} side` } }); ref.afterClosed().subscribe((cropped: File | null) => { if (!cropped) return; this.setSlotFile(section, slot, cropped); this.maybeMerge(section); }); } private setSlotFile(section: SectionKey, slot: 'front' | 'back', file: File): void { if (section === 'id') this.idFiles[slot] = file; else this.addrFiles[slot] = file; } /** * If a section has both image sides ready, build a single merged PNG * (front stacked above back) and store it as the file we'll actually upload. */ private maybeMerge(section: SectionKey): void { const proof = section === 'id' ? this.selectedIdProof : this.selectedAddrProof; if (!proof || proof.sides !== 2) { this.resetMerged(section); return; } const front = section === 'id' ? this.idFiles.front : this.addrFiles.front; const back = section === 'id' ? this.idFiles.back : this.addrFiles.back; if (!front || !back) { this.resetMerged(section); return; } if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) { // Cannot merge non-image files (PDFs); leave as separate. this.resetMerged(section); return; } Promise.all([ this.fileToImage(front), this.fileToImage(back) ]) .then( ([ fImg, bImg ]) => { const targetWidth = Math.max(fImg.width, bImg.width); const fScale = targetWidth / fImg.width; const bScale = targetWidth / bImg.width; const fH = fImg.height * fScale; const bH = bImg.height * bScale; const gap = 20; const totalH = Math.round(fH + bH + gap); const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = totalH; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(fImg, 0, 0, targetWidth, fH); ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH); canvas.toBlob((blob) => { if (!blob) return; const merged = new File([blob], `${proof.name || 'document'}-combined.png`, { type: 'image/png' }); const preview = canvas.toDataURL('image/png'); if (section === 'id') { this.idMergedFile = merged; this.idMergedPreview = preview; } else { this.addrMergedFile = merged; this.addrMergedPreview = preview; } }, 'image/png'); } ) .catch(() => this.resetMerged(section)); } private resetMerged(section: SectionKey): void { if (section === 'id') { this.idMergedFile = null; this.idMergedPreview = null; } else { this.addrMergedFile = null; this.addrMergedPreview = null; } } private fileToImage(file: File): Promise { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image load failed')); }; img.src = url; }); } getFileName(section: SectionKey, slot: 'front' | 'back'): string { const f = section === 'id' ? this.idFiles[slot] : this.addrFiles[slot]; if (!f) return ''; return f.name.length > 30 ? f.name.slice(0, 27) + '...' : f.name; } isIdComplete(): boolean { return this.isSectionComplete('id'); } isAddressComplete(): boolean { return this.isSectionComplete('address'); } /** Section is "filled in" — has a proof selected */ private isSectionTouched(section: SectionKey): boolean { return section === 'id' ? !!this.selectedIdProof : !!this.selectedAddrProof; } /** Section has all required fields + files */ private isSectionComplete(section: SectionKey): boolean { if (section === 'id') { const proof = this.selectedIdProof; if (!proof) return false; const v = this.uploadDocumentForm.get('id').value; if (!v.documentKey) return false; if (!this.idFiles.front) return false; if (proof.sides === 2 && !this.idFiles.back) return false; return true; } const proof = this.selectedAddrProof; if (!proof) return false; if (!this.addrFiles.front) return false; if (proof.sides === 2 && !this.addrFiles.back) return false; return true; } get canSubmit(): boolean { if (!this.documentIdentifier) { return this.uploadDocumentForm?.valid; } const idTouched = this.isSectionTouched('id'); const addrTouched = this.isSectionTouched('address'); if (!idTouched && !addrTouched) return false; if (idTouched && !this.isSectionComplete('id')) return false; if (addrTouched && !this.isSectionComplete('address')) return false; return true; } submit(): void { if (!this.documentIdentifier) { this.dialogRef.close(this.uploadDocumentForm.value); return; } const result: any = { description: this.uploadDocumentForm.get('description').value, id: null, address: null }; if (this.isSectionComplete('id')) { const v = this.uploadDocumentForm.get('id').value; result.id = { proof: this.selectedIdProof, proofId: this.selectedIdProof.id, documentKey: v.documentKey, fullName: this.clientName, files: this.buildSectionFiles('id') }; } if (this.isSectionComplete('address')) { const v = this.uploadDocumentForm.get('address').value; result.address = { proof: this.selectedAddrProof, proofId: this.selectedAddrProof.id, address: this.clientAddress, issueDate: v.issueDate, files: this.buildSectionFiles('address') }; } this.dialogRef.close(result); } /** * Build the files payload for a section. When both sides are images and * have been merged into a single combined PNG, send only that one file. * Otherwise fall back to the individual front/back files. */ private buildSectionFiles(section: SectionKey): { slot: string; file: File }[] { const proof = section === 'id' ? this.selectedIdProof : this.selectedAddrProof; const front = section === 'id' ? this.idFiles.front : this.addrFiles.front; const back = section === 'id' ? this.idFiles.back : this.addrFiles.back; const merged = section === 'id' ? this.idMergedFile : this.addrMergedFile; if (proof?.sides === 2 && merged) { return [{ slot: 'Combined', file: merged }]; } const files: { slot: string; file: File }[] = []; if (front) files.push({ slot: 'Front', file: front }); if (proof?.sides === 2 && back) files.push({ slot: 'Back', file: back }); return files; } /** Legacy: kept for non-identifier mode file upload component */ onFileSelect($event: any) { if ($event.target.files.length > 0) { const file = $event.target.files[0]; this.uploadDocumentForm.get('file').setValue(file); this.capturedFileName = null; } } /** Non-identifier (generic Documents tab) mode only — lets staff photograph a document * with the webcam instead of only being able to browse for an existing file. Reuses the * same capture dialog as the profile-photo/signature flows. */ captureFromWebcam(): void { const ref = this.dialog.open(CaptureImageDialogComponent, { width: '720px', maxWidth: '95vw', data: { captureTarget: 'profile' } }); ref.afterClosed().subscribe((dataUrl: string | null) => { if (!dataUrl) return; const file = dataUrlToFile(dataUrl, `webcam-capture-${Date.now()}.jpg`); this.uploadDocumentForm.get('file').setValue(file); this.capturedFileName = file.name; }); } }