Files
cbsnexora/src/app/clients/clients-view/custom-dialogs/capture-image-dialog/capture-image-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

172 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/.
*/
/** Angular Imports */
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { NgStyle } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the captured image is for — drives the dialog's title/instructions and lets the
* caller decide (based on how it opened the dialog) which upload method to invoke on the
* resulting data URL. Defaults to 'profile' so existing callers that don't pass `data`
* keep working unchanged. */
export type CaptureImageTarget = 'profile' | 'signature';
/**
* Capture image dialog component
*/
@Component({
selector: 'mifosx-capture-image-dialog',
templateUrl: './capture-image-dialog.component.html',
styleUrls: ['./capture-image-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
NgStyle,
FaIconComponent,
MatDialogActions,
MatDialogClose
]
})
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
private renderer = inject(Renderer2);
private data = inject<{ captureTarget?: CaptureImageTarget } | null>(MAT_DIALOG_DATA, { optional: true });
/** 'profile' (default, unchanged behaviour) or 'signature' — photographing a paper signature. */
captureTarget: CaptureImageTarget = this.data?.captureTarget || 'profile';
get isSignatureCapture(): boolean {
return this.captureTarget === 'signature';
}
get dialogTitle(): string {
return this.isSignatureCapture ? 'Capture Client Signature' : 'Capture Client Image';
}
get dialogInstructions(): string {
return this.isSignatureCapture
? 'Photograph the customer’s signed paper — line up the signature within the frame before capturing.'
: 'Line up the customer’s face within the frame before capturing.';
}
/** Video element reference */
@ViewChild('video', { static: true }) video: ElementRef;
/** Canvas element reference */
@ViewChild('canvas', { static: true }) canvas: ElementRef;
/** Fallback element reference */
@ViewChild('fallback', { static: true }) fallback: ElementRef;
/** Capture button element reference */
@ViewChild('captureButton') captureButton: ElementRef;
/** Toggles button states */
isCaptured = false;
/** Client image file */
clientImageDataURL: string;
ngAfterViewInit() {
this.startCamera();
}
ngOnDestroy() {
this.stopCamera();
}
/**
* Initializes camera video stream once user grants permission.
* Sets fallback if permission not granted.
* See https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices for configuration details.
*/
startCamera() {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
// Signature capture asks for a wide/short (16:9) stream to match the frame it's shown
// in — requesting an aspect ratio close to what the CSS box actually renders avoids the
// camera negotiating a 4:3 stream that then gets pillarboxed (black bars) inside a 16:9 box.
const videoConstraints: MediaTrackConstraints = this.isSignatureCapture
? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }
: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' };
navigator.mediaDevices
.getUserMedia({ video: videoConstraints })
.then((stream: MediaStream) => {
this.renderer.setProperty(this.video.nativeElement, 'srcObject', stream);
this.video.nativeElement.play();
})
.catch((error: Error) => {
this.handleError(error);
});
} else {
throw new Error('Cannot connect to camera');
}
}
/**
* Shows fallback message.
* @param {Error} error Error
*/
handleError(error: Error) {
this.renderer.removeStyle(this.fallback.nativeElement, 'display');
const fallbackMessage = this.renderer.createText(`${error.name}: ${error.message}`);
this.renderer.appendChild(this.fallback.nativeElement, fallbackMessage);
this.renderer.setStyle(this.video.nativeElement, 'display', 'none');
this.renderer.setProperty(this.captureButton, 'disabled', 'true');
}
/**
* Stops video stream and closes the camera.
* See https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack for `MediaStreamTrack` properties.
*/
stopCamera() {
const stream: MediaStream = this.video.nativeElement.srcObject;
if (stream) {
const videoStream: MediaStreamTrack = stream.getTracks()[0];
videoStream.stop();
}
this.renderer.setProperty(this.video.nativeElement, 'srcObject', null);
}
/**
* Captures the image by drawing video state on canvas, then converts canvas state to data URL.
* Uses actual video dimensions to prevent aspect ratio distortion.
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL for details.
*/
capture() {
this.isCaptured = true;
this.video.nativeElement.pause();
// Get actual video stream dimensions to preserve aspect ratio
const videoWidth = this.video.nativeElement.videoWidth;
const videoHeight = this.video.nativeElement.videoHeight;
// Set canvas dimensions to match video stream
this.canvas.nativeElement.width = videoWidth;
this.canvas.nativeElement.height = videoHeight;
// Draw the video frame at full resolution without distortion
this.canvas.nativeElement.getContext('2d').drawImage(this.video.nativeElement, 0, 0, videoWidth, videoHeight);
// Convert to data URL with JPEG format for smaller file size
this.clientImageDataURL = this.canvas.nativeElement.toDataURL('image/jpeg', 0.9);
}
/**
* Allows user to capture image again.
*/
recapture() {
this.isCaptured = false;
this.video.nativeElement.play();
}
}