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>
469 lines
20 KiB
HTML
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>
|