Files
cbsnexora/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html
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

469 lines
20 KiB
HTML

<!--
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/.
-->
<div class="fd-dialog" [class.fd-dialog--kyc]="documentIdentifier">
<div class="fd-header">
<h1 mat-dialog-title class="fd-title">
@if (documentIdentifier) {
<i class="fa fa-id-card fd-title__icon"></i>
{{ 'labels.heading.Add Client Identifier' | translate }}
} @else {
<i class="fa fa-upload fd-title__icon"></i>
{{ 'labels.heading.Upload' | translate }} {{ entityType }} {{ 'labels.heading.Documents' | translate }}
}
</h1>
<button mat-icon-button mat-dialog-close class="fd-close" aria-label="Close">
<i class="fa fa-times"></i>
</button>
</div>
<div class="fd-content">
@if (documentIdentifier) {
<div class="kyc-intro">
<i class="fa fa-shield kyc-intro__icon"></i>
<div>
<strong>KYC Document Upload</strong>
<p>
Upload KYC documents as per RBI guidelines. Accepted:
<i class="fa fa-file-image-o"></i> JPG, <i class="fa fa-file-image-o"></i> PNG,
<i class="fa fa-file-pdf-o"></i> PDF — max 5MB each.
</p>
</div>
</div>
@if (loadingProofs) {
<div class="kyc-loading"><i class="fa fa-spinner fa-spin"></i> Loading document types…</div>
}
<form [formGroup]="uploadDocumentForm" class="kyc-form">
<!-- SECTION 1: IDENTITY PROOF -->
<div class="kyc-section" formGroupName="id">
<div class="kyc-section__header">
<div class="kyc-badge kyc-badge--blue">
<i class="fa fa-user-circle-o"></i>
</div>
<div class="kyc-section__titles">
<p class="kyc-section__title"><i class="fa fa-id-badge"></i> Identity proof</p>
<p class="kyc-section__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
<!-- Prefilled name (from client registration) -->
@if (clientName) {
<div class="kyc-prefill">
<i class="fa fa-user"></i>
<div>
<span class="kyc-prefill__label">Full name (from registration)</span>
<span class="kyc-prefill__value">{{ clientName }}</span>
</div>
<i class="fa fa-lock kyc-prefill__lock" title="Prefilled from client registration"></i>
</div>
}
<div class="kyc-field kyc-mb-sm">
<label class="kyc-label"> <i class="fa fa-file-text-o"></i> Document type </label>
<div class="kyc-select-wrap">
<i class="fa fa-id-card kyc-select-wrap__icon"></i>
<select
class="kyc-input kyc-select"
formControlName="proofId"
(change)="onIdProofChange($any($event.target).value)"
>
<option value="">— Select document —</option>
@for (p of idProofs; track p.id) {
<option [value]="p.id">{{ p.name }}</option>
}
</select>
<i class="fa fa-chevron-down kyc-select-wrap__chevron"></i>
</div>
</div>
@if (selectedIdProof) {
<div class="kyc-field kyc-mb-sm">
<label class="kyc-label"> <i class="fa fa-hashtag"></i> {{ selectedIdProof.documentKeyLabel }} </label>
<div class="kyc-input-wrap">
<i class="fa fa-pencil kyc-input-wrap__icon"></i>
<input
type="text"
class="kyc-input kyc-input--padded"
formControlName="documentKey"
[placeholder]="selectedIdProof.documentKeyPlaceholder || 'Enter document number'"
/>
</div>
</div>
}
@if (selectedIdProof?.info) {
<div class="kyc-banner kyc-banner--blue">
<i class="fa fa-info-circle"></i>
<span>{{ selectedIdProof.info }}</span>
</div>
}
@if (selectedIdProof) {
<div class="kyc-upload-grid" [class.kyc-upload-grid--two]="selectedIdProof.sides === 2">
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i>
{{ selectedIdProof.sides === 2 ? 'Front side' : selectedIdProof.name + ' (single copy)' }}
</span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="idFiles.front"
[class.kyc-dropzone--req]="!idFiles.front"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('id', 'front', $event)"
/>
@if (idFiles.front) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name"><i class="fa fa-paperclip"></i> {{ getFileName('id', 'front') }}</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
@if (selectedIdProof.sides === 2) {
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label"> <i class="fa fa-file-image-o"></i> Back side </span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="idFiles.back"
[class.kyc-dropzone--req]="!idFiles.back"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('id', 'back', $event)"
/>
@if (idFiles.back) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name"><i class="fa fa-paperclip"></i> {{ getFileName('id', 'back') }}</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
}
</div>
@if (idMergedPreview && selectedIdProof?.sides === 2) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">
<i class="fa fa-object-group"></i> Combined preview (front + back)
</span>
<span class="kyc-merged__hint">
Saved as a single PNG. Re-crop a side to update the merged file.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="idMergedPreview" alt="Merged ID document" />
</div>
<div class="kyc-merged__actions">
<button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'front')">
<i class="fa fa-crop"></i> Re-crop front
</button>
<button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'back')">
<i class="fa fa-crop"></i> Re-crop back
</button>
</div>
</div>
}
}
</div>
<!-- SECTION 2: ADDRESS PROOF -->
<div class="kyc-section" formGroupName="address">
<div class="kyc-section__header">
<div class="kyc-badge kyc-badge--teal">
<i class="fa fa-map-marker"></i>
</div>
<div class="kyc-section__titles">
<p class="kyc-section__title"><i class="fa fa-home"></i> Address proof</p>
<p class="kyc-section__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
<!-- Prefilled permanent address -->
@if (clientAddress) {
<div class="kyc-prefill kyc-prefill--teal">
<i class="fa fa-map-marker"></i>
<div>
<span class="kyc-prefill__label">Permanent address (from registration)</span>
<span class="kyc-prefill__value">{{ clientAddress }}</span>
</div>
<i class="fa fa-lock kyc-prefill__lock" title="Prefilled from client registration"></i>
</div>
} @else {
<div class="kyc-banner kyc-banner--amber">
<i class="fa fa-exclamation-triangle"></i>
<span>No permanent address found on file. Add one in the Address tab first.</span>
</div>
}
<div class="kyc-field">
<label class="kyc-label"> <i class="fa fa-file-text-o"></i> Document type </label>
<div class="kyc-chip-wrap">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="kyc-chip-group-label"><i class="fa fa-folder-open-o"></i> {{ group.label }}</div>
<div class="kyc-chip-grid">
@for (p of group.items; track p.id) {
<span
class="kyc-chip"
[class.kyc-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
role="button"
>
@if (isAddrProofSelected(p)) {
<i class="fa fa-check-circle"></i>
} @else {
<i class="fa fa-circle-o"></i>
}
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="kyc-chip-divider"></div>
}
}
</div>
</div>
@if (selectedAddrProof?.info) {
<div class="kyc-banner kyc-banner--teal">
<i class="fa fa-check-circle"></i>
<span>{{ selectedAddrProof.info }}</span>
</div>
}
@if (selectedAddrProof) {
<div class="kyc-upload-grid" [class.kyc-upload-grid--two]="selectedAddrProof.sides === 2">
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i>
{{ selectedAddrProof.sides === 2 ? 'Front side' : selectedAddrProof.name + ' (single copy)' }}
</span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="addrFiles.front"
[class.kyc-dropzone--req]="!addrFiles.front"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('address', 'front', $event)"
/>
@if (addrFiles.front) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'front') }}
</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label"> <i class="fa fa-file-image-o"></i> Back side </span>
<span class="kyc-tag kyc-tag--req"> <i class="fa fa-asterisk"></i> Required </span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="addrFiles.back"
[class.kyc-dropzone--req]="!addrFiles.back"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('address', 'back', $event)"
/>
@if (addrFiles.back) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'back') }}
</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG · <i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
}
@if (selectedAddrProof.requiresDate) {
<div class="kyc-field kyc-field--date">
<label class="kyc-label">
<i class="fa fa-calendar"></i> Issue / statement date
<span class="kyc-opt">(not older than 3 months)</span>
</label>
<div class="kyc-input-wrap kyc-input-wrap--date">
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
</div>
</div>
}
</div>
@if (addrMergedPreview && selectedAddrProof?.sides === 2) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">
<i class="fa fa-object-group"></i> Combined preview (front + back)
</span>
<span class="kyc-merged__hint">
Saved as a single PNG. Re-crop a side to update the merged file.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="addrMergedPreview" alt="Merged address document" />
</div>
<div class="kyc-merged__actions">
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'front')">
<i class="fa fa-crop"></i> Re-crop front
</button>
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'back')">
<i class="fa fa-crop"></i> Re-crop back
</button>
</div>
</div>
}
}
</div>
<div class="kyc-section kyc-section--notes">
<label class="kyc-label">
<i class="fa fa-sticky-note-o"></i> Description <span class="kyc-opt">(optional)</span>
</label>
<textarea
class="kyc-textarea"
formControlName="description"
placeholder="Add any additional notes about these documents…"
rows="2"
></textarea>
</div>
<div class="kyc-status-bar">
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isIdComplete()">
@if (isIdComplete()) {
<i class="fa fa-check"></i>
}
</span>
<i class="fa fa-id-badge"></i> Identity proof
</span>
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isAddressComplete()">
@if (isAddressComplete()) {
<i class="fa fa-check"></i>
}
</span>
<i class="fa fa-home"></i> Address proof
</span>
<span class="kyc-status-hint"> <i class="fa fa-info-circle"></i> Fill in at least one section </span>
</div>
</form>
} @else {
<form [formGroup]="uploadDocumentForm" class="fd-form">
<div class="fd-grid fd-grid--cols-2">
<div class="fd-field">
<mat-form-field class="flex-fill" appearance="outline">
<mat-label>{{ 'labels.inputs.File Name' | translate }}</mat-label>
<input formControlName="fileName" required matInput />
@if (uploadDocumentForm.controls.fileName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.File Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="fd-field">
<mat-form-field class="flex-fill" appearance="outline">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<input formControlName="description" matInput />
</mat-form-field>
</div>
</div>
<div class="fd-field fd-field--file">
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
</div>
<div class="fd-webcam-row">
<span class="fd-webcam-divider">or</span>
<button type="button" mat-stroked-button color="primary" class="fd-webcam-btn" (click)="captureFromWebcam()">
<i class="fa fa-camera"></i> Capture from Webcam
</button>
@if (capturedFileName) {
<span class="fd-webcam-confirm"><i class="fa fa-check-circle"></i> {{ capturedFileName }}</span>
}
</div>
</form>
}
</div>
<mat-dialog-actions class="fd-actions" align="end">
<button mat-stroked-button mat-dialog-close class="fd-btn fd-btn--cancel">
<i class="fa fa-times fd-btn__icon"></i>
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-flat-button color="primary" [disabled]="!canSubmit" (click)="submit()" class="fd-btn fd-btn--submit">
<i class="fa fa-check fd-btn__icon"></i>
@if (documentIdentifier) {
{{ 'labels.buttons.Submit' | translate }}
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
</mat-dialog-actions>
</div>