Files
cbsnexora/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts
T
niteshandClaude Sonnet 5 733d9d22b2
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat(clients): dual-OTP contact-change verification, webcam signature capture, and client history tab
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>
2026-07-26 14:51:04 +05:30

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;
});
}
}