Frontend counterpart to the backend dual-OTP contact-change feature:
- New ContactOtpBlockComponent (6-box segmented OTP input) used up to 4x in
Edit Client (old/new mobile, old/new email — grouped under per-field
sub-headers) and once each for mobile/email in Create Client, gating
Save/Save & Next until every required box is verified.
- edit-client.component.ts: detects mobile/email/DOB changes against the
loaded snapshot using local-date comparison (not `.toISOString()`, which
false-triggers a day-shift in positive-UTC-offset timezones) and
trimmed/case-insensitive email comparison (autofill whitespace or re-typed
casing no longer reads as a change the masked preview can't explain).
- client-kyc-step.component.ts: new `invalidFieldLabels` getter names every
currently-invalid required KYC field, surfaced via the Save & Next tooltip
in both create-client and edit-client so a greyed-out button always
explains what's still missing instead of a generic message.
- CaptureImageDialogComponent generalized for both profile photo and
signature (photograph-a-paper-signature) capture:
- Frame aspect now matches the requested getUserMedia resolution
(4:3 profile / 16:9 signature) with object-fit: cover, eliminating the
pillarboxing (black bars) a mismatched box previously caused.
- Suppresses Chromium's auto-injected PiP/cast corner overlay icons.
- Frame height capped in vh (not just px) so the dialog fits short
viewports instead of pushing Cancel/Upload below the fold.
- upload-document-dialog.component.ts: generic Documents-tab upload now also
offers "Capture from Webcam" alongside Browse, reusing the same capture
dialog.
- New client History tab (reuses the existing Audit Trail data, scoped to
this client) and dataUrlToFile/dataUrlToBlob util for routing webcam
captures through the same upload pipelines as a picked file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
/**
|
|
* 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<UploadDocumentDialogComponent>>(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<HTMLImageElement> {
|
|
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;
|
|
});
|
|
}
|
|
}
|