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>
This commit is contained in:
@@ -30,6 +30,8 @@ import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
@@ -128,6 +130,31 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
markPhotoSigTouched(): void {
|
||||
this.photoSigTouched = true;
|
||||
}
|
||||
|
||||
/** Human-readable label for each `kycForm` control that can carry a `required` error —
|
||||
* used to build a specific "why is Save & Next disabled" message instead of a generic one.
|
||||
* Controls not listed here (e.g. purely pattern-validated ones like panNumber/gstinNumber)
|
||||
* only block when the user has actually typed something invalid, which is covered separately. */
|
||||
private static readonly REQUIRED_FIELD_LABELS: Record<string, string> = {
|
||||
kycDocumentName: 'KYC Document Type',
|
||||
kycDocumentNumber: 'KYC Document Number',
|
||||
amlCheckStatus: 'AML Check Status',
|
||||
bannedEntityMatch: 'Banned Entity List Match'
|
||||
};
|
||||
|
||||
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||
* that fails its pattern) in plain English — surfaced via the Save & Next tooltip so staff
|
||||
* aren't left staring at a greyed-out button with no clue what's still missing. Whether or
|
||||
* not photo/signature previews are filled has no bearing on this list (they aren't part of
|
||||
* `kycForm` at all — see `photoSigComplete`). */
|
||||
get invalidFieldLabels(): string[] {
|
||||
if (!this.kycForm) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(this.kycForm.controls)
|
||||
.filter((key) => this.kycForm.get(key)?.invalid)
|
||||
.map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key);
|
||||
}
|
||||
/** Fallback document types when template API is unavailable */
|
||||
private defaultDocumentTypes = [
|
||||
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
||||
@@ -1366,6 +1393,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
await this.openPhotoCropDialog(file);
|
||||
}
|
||||
|
||||
/** Webcam capture entry point for the profile photograph — feeds the captured frame
|
||||
* through the same crop dialog + staging/upload path as the file-picker flow above,
|
||||
* so behaviour (immediate upload in edit mode vs staged-until-submit in create mode)
|
||||
* stays identical regardless of capture source. */
|
||||
async captureWebcamPhoto(): Promise<void> {
|
||||
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||
data: { captureTarget: 'profile' }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
||||
if (!imageDataUrl) return;
|
||||
const file = dataUrlToFile(imageDataUrl, 'webcam-photograph.jpg');
|
||||
await this.openPhotoCropDialog(file);
|
||||
});
|
||||
}
|
||||
|
||||
private async openPhotoCropDialog(file: File): Promise<void> {
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
width: '680px',
|
||||
@@ -1391,6 +1437,24 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
await this.openSignatureCropDialog(file);
|
||||
}
|
||||
|
||||
/** Webcam capture entry point for the specimen signature — photographs a paper
|
||||
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
|
||||
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
|
||||
async captureWebcamSignature(): Promise<void> {
|
||||
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||
data: { captureTarget: 'signature' }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
||||
if (!imageDataUrl) return;
|
||||
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
|
||||
await this.openSignatureCropDialog(file);
|
||||
});
|
||||
}
|
||||
|
||||
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
width: '680px',
|
||||
|
||||
Reference in New Issue
Block a user