feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements
- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management - OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling - Two-factor authentication login flow updates - Client KYC, India address, family-members, ckyc, nominee enhancements - New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service) - User create/edit/view enhancements - Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
@@ -640,33 +640,163 @@
|
||||
}
|
||||
</h4>
|
||||
|
||||
<!-- Chip-grouped document type picker -->
|
||||
<div class="kyc-doc-pickbox">
|
||||
<div class="kyc-doc-pickbox__head">
|
||||
<mat-icon class="kyc-doc-pickbox__icon">badge</mat-icon>
|
||||
<div class="kyc-doc-pickbox__titles">
|
||||
<p class="kyc-doc-pickbox__title">Identity proof</p>
|
||||
<p class="kyc-doc-pickbox__sub">Select document type — upload requirements will update automatically</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kyc-doc-field">
|
||||
<label class="kyc-doc-label"><mat-icon>description</mat-icon> Document type</label>
|
||||
<div class="kyc-doc-chip-wrap">
|
||||
@for (group of kycDocGroups; track group.label; let last = $last) {
|
||||
<div class="kyc-doc-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
|
||||
<div class="kyc-doc-chip-grid">
|
||||
@for (p of group.items; track p.id) {
|
||||
<span
|
||||
class="kyc-doc-chip"
|
||||
[class.kyc-doc-chip--sel]="isKycDocSelected(p)"
|
||||
(click)="selectKycDoc(p)"
|
||||
(keydown.enter)="selectKycDoc(p)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<mat-icon class="kyc-doc-chip__icon">
|
||||
{{ isKycDocSelected(p) ? 'check_circle' : 'radio_button_unchecked' }}
|
||||
</mat-icon>
|
||||
{{ p.name }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (!last) {
|
||||
<div class="kyc-doc-chip-divider"></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
|
||||
<div class="kyc-doc-error">KYC Document Type is <strong>required</strong></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc?.info) {
|
||||
<div class="kyc-doc-banner">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>{{ selectedKycDoc.info }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedKycDoc) {
|
||||
<div class="kyc-doc-upload-grid" [class.kyc-doc-upload-grid--two]="selectedKycDoc.sides === 2">
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title">
|
||||
<mat-icon>image</mat-icon>
|
||||
{{ selectedKycDoc.sides === 2 ? 'Front side' : selectedKycDoc.name + ' (single copy)' }}
|
||||
</span>
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
|
||||
@if (kycDocFiles.front) {
|
||||
<div class="kyc-doc-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kyc-doc-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('front')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc.sides === 2) {
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
|
||||
@if (kycDocFiles.back) {
|
||||
<div class="kyc-doc-dropzone__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kyc-doc-dropzone__remove"
|
||||
(click)="$event.preventDefault(); removeKycDocFile('back')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-dropzone__hint">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedKycDoc.sides === 2 && kycDocMergedPreview) {
|
||||
<div class="kyc-merged">
|
||||
<div class="kyc-merged__head">
|
||||
<span class="kyc-merged__title">
|
||||
<mat-icon>filter_none</mat-icon>
|
||||
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]="kycDocMergedPreview" alt="Merged KYC document" />
|
||||
</div>
|
||||
<div class="kyc-merged__actions">
|
||||
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('front')">
|
||||
<mat-icon>crop</mat-icon> Re-crop front
|
||||
</button>
|
||||
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('back')">
|
||||
<mat-icon>crop</mat-icon> Re-crop back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>KYC Document Type</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
|
||||
<mat-select required formControlName="kycDocumentName">
|
||||
@for (doc of kycDocumentTypes; track doc) {
|
||||
<mat-option [value]="doc.name">
|
||||
{{ doc.name }}
|
||||
@if (doc.mandatory) {
|
||||
<span class="doc-mandatory-star"> *</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
|
||||
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Document Number</mat-label>
|
||||
<mat-label>{{ selectedKycDoc?.documentKeyLabel || 'Document Number' }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="kycDocumentNumber"
|
||||
placeholder="As printed on document"
|
||||
[placeholder]="selectedKycDoc?.documentKeyPlaceholder || 'As printed on document'"
|
||||
maxlength="30"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
@@ -1005,9 +1135,14 @@
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||
Photograph & Signature
|
||||
<span class="section-required-tag">Required</span>
|
||||
</h4>
|
||||
<div class="photo-sig-grid">
|
||||
<div class="upload-box" [class.upload-box--has-preview]="photographPreview">
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="photographPreview"
|
||||
[class.upload-box--missing]="!photographPreview && photoSigTouched"
|
||||
>
|
||||
@if (photographPreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
|
||||
@@ -1023,15 +1158,25 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
||||
<span class="upload-box__label">Client Photograph</span>
|
||||
<span class="upload-box__label">
|
||||
Client Photograph
|
||||
<span class="upload-box__req">*</span>
|
||||
</span>
|
||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
@if (photoSigTouched) {
|
||||
<span class="upload-box__error">Photograph is required</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview">
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="signaturePreview"
|
||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||
>
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
@@ -1047,12 +1192,18 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||
<span class="upload-box__label">Specimen Signature</span>
|
||||
<span class="upload-box__label">
|
||||
Specimen Signature
|
||||
<span class="upload-box__req">*</span>
|
||||
</span>
|
||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
@if (photoSigTouched) {
|
||||
<span class="upload-box__error">Signature is required</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,19 @@
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-required-tag {
|
||||
margin-left: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-badge {
|
||||
@@ -948,12 +961,23 @@
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&--missing {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
|
||||
&:hover {
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -972,6 +996,19 @@
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__req {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
@@ -1214,3 +1251,393 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== KYC Document chip-grouped picker ===================== */
|
||||
.kyc-doc-pickbox {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
margin: 6px 0 14px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
border-radius: 999px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-field {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kyc-doc-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-chip-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.kyc-doc-chip-group-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kyc-doc-chip-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 14px 0 10px;
|
||||
}
|
||||
|
||||
.kyc-doc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&--sel {
|
||||
background: #e3f2fd;
|
||||
border-color: #1976d2;
|
||||
color: #0d47a1;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 0 0 1px #1976d2;
|
||||
|
||||
.kyc-doc-chip__icon {
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #1976d2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #059669;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-error {
|
||||
margin-top: 8px;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== KYC Document upload dropzones ===================== */
|
||||
.kyc-doc-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
|
||||
&--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (width <= 720px) {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-upload-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kyc-doc-upload-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kyc-doc-upload-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-upload-req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kyc-merged {
|
||||
margin-top: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 360px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 6px rgb(15 23 42 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-dropzone {
|
||||
display: block;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
padding: 18px 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
&--req {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&--has {
|
||||
border-style: solid;
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
mat-icon {
|
||||
color: #16a34a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,28 @@
|
||||
|
||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import {
|
||||
MatDialog,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { Inject } from '@angular/core';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatSelectTrigger } from '@angular/material/select';
|
||||
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
@@ -31,6 +40,8 @@ import { Dates } from 'app/core/utils/dates';
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatButton,
|
||||
MatCheckbox,
|
||||
MatProgressSpinner,
|
||||
FaIconComponent,
|
||||
@@ -43,6 +54,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private kycUpload = inject(KycUploadHelperService);
|
||||
|
||||
/** Set by parent after client is created */
|
||||
@Input() clientId: string = '';
|
||||
@@ -67,15 +79,47 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
/** KYC Document Types — populated from API template, falls back to defaults */
|
||||
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
|
||||
|
||||
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
||||
kycDocProofs: ProofType[] = [];
|
||||
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
||||
selectedKycDoc: ProofType | null = null;
|
||||
/** Front/back files for the chosen KYC document */
|
||||
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||
/** Combined front+back PNG (built automatically once both image sides are ready) */
|
||||
kycDocMergedFile: File | null = null;
|
||||
kycDocMergedPreview: string | null = null;
|
||||
|
||||
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
|
||||
photoSigTouched = false;
|
||||
|
||||
/** Either an in-memory crop or an image already on the server */
|
||||
get hasPhoto(): boolean {
|
||||
return !!this.photographPreview;
|
||||
}
|
||||
|
||||
get hasSignature(): boolean {
|
||||
return !!this.signaturePreview;
|
||||
}
|
||||
|
||||
/** Photo + signature are mandatory before leaving the KYC tab */
|
||||
get photoSigComplete(): boolean {
|
||||
return this.hasPhoto && this.hasSignature;
|
||||
}
|
||||
|
||||
/** Mark uploads as touched so the UI shows the missing-required state */
|
||||
markPhotoSigTouched(): void {
|
||||
this.photoSigTouched = true;
|
||||
}
|
||||
/** Fallback document types when template API is unavailable */
|
||||
private defaultDocumentTypes = [
|
||||
'Aadhaar Card',
|
||||
'Passport',
|
||||
'PAN Card',
|
||||
'Driving Licence',
|
||||
'Voter ID',
|
||||
'NREGA Job Card',
|
||||
'National Population Register Letter'
|
||||
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
||||
{ name: 'Passport', code: 'PASSPORT' },
|
||||
{ name: 'PAN Card', code: 'PAN' },
|
||||
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
||||
{ name: 'Voter ID', code: 'VOTER_ID' },
|
||||
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
||||
{ name: 'National Population Register Letter', code: 'NPR_LETTER' }
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
@@ -371,6 +415,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Seed defaults immediately so the picker has options before clientId is set
|
||||
// (template API requires clientId; user can still pick a type pre-save).
|
||||
if (!this.kycDocumentTypes.length) {
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
this.loadExistingKycIfNeeded();
|
||||
this.loadKycTemplate();
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
@@ -412,9 +466,111 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// Reset selected document type since options changed
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild chip-group picker model from kycDocumentTypes */
|
||||
private rebuildKycDocPicker(): void {
|
||||
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
||||
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
|
||||
);
|
||||
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
||||
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
||||
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
||||
this.selectedKycDoc = currentName ? this.kycDocProofs.find((p) => p.name === currentName) || null : null;
|
||||
}
|
||||
|
||||
selectKycDoc(proof: ProofType): void {
|
||||
this.selectedKycDoc = proof;
|
||||
this.kycForm.get('kycDocumentName')?.setValue(proof.name);
|
||||
// Reset uploads + merged preview when document type changes
|
||||
this.kycDocFiles = { front: null, back: null };
|
||||
this.kycDocMergedFile = null;
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
isKycDocSelected(proof: ProofType): boolean {
|
||||
return this.selectedKycDoc?.code === proof.code && this.selectedKycDoc?.name === proof.name;
|
||||
}
|
||||
|
||||
async onKycDocFile(event: Event, slot: 'front' | 'back'): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const raw = input.files?.[0];
|
||||
input.value = ''; // allow re-picking the same filename
|
||||
if (!raw) return;
|
||||
if (raw.size > 5 * 1024 * 1024) return;
|
||||
|
||||
const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
|
||||
if (!cropped) return;
|
||||
this.kycDocFiles[slot] = cropped;
|
||||
await this.refreshKycDocMerged();
|
||||
}
|
||||
|
||||
async reCropKycDocFile(slot: 'front' | 'back'): Promise<void> {
|
||||
const current = this.kycDocFiles[slot];
|
||||
if (!current || !current.type.startsWith('image/')) return;
|
||||
const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
|
||||
if (!cropped) return;
|
||||
this.kycDocFiles[slot] = cropped;
|
||||
await this.refreshKycDocMerged();
|
||||
}
|
||||
|
||||
removeKycDocFile(slot: 'front' | 'back'): void {
|
||||
this.kycDocFiles[slot] = null;
|
||||
this.kycDocMergedFile = null;
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
private async refreshKycDocMerged(): Promise<void> {
|
||||
const proof = this.selectedKycDoc;
|
||||
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
|
||||
this.kycDocMergedFile = null;
|
||||
this.kycDocMergedPreview = null;
|
||||
return;
|
||||
}
|
||||
const merged = await this.kycUpload.mergeFrontBack(
|
||||
this.kycDocFiles.front,
|
||||
this.kycDocFiles.back,
|
||||
proof.name || 'kyc-document'
|
||||
);
|
||||
if (merged) {
|
||||
this.kycDocMergedFile = merged.file;
|
||||
this.kycDocMergedPreview = merged.preview;
|
||||
} else {
|
||||
this.kycDocMergedFile = null;
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files + proof metadata for the parent to persist as a client identifier.
|
||||
* When both image sides are present they're sent as a single combined PNG;
|
||||
* otherwise the individual files are returned.
|
||||
*/
|
||||
get kycDocPayload(): {
|
||||
proof: ProofType | null;
|
||||
proofId: any;
|
||||
documentKey: string;
|
||||
files: { slot: string; file: File }[];
|
||||
} {
|
||||
const files: { slot: string; file: File }[] = [];
|
||||
if (this.selectedKycDoc?.sides === 2 && this.kycDocMergedFile) {
|
||||
files.push({ slot: 'Combined', file: this.kycDocMergedFile });
|
||||
} else {
|
||||
if (this.kycDocFiles.front) files.push({ slot: 'Front', file: this.kycDocFiles.front });
|
||||
if (this.selectedKycDoc?.sides === 2 && this.kycDocFiles.back) {
|
||||
files.push({ slot: 'Back', file: this.kycDocFiles.back });
|
||||
}
|
||||
}
|
||||
return {
|
||||
proof: this.selectedKycDoc,
|
||||
proofId: this.selectedKycDoc?.id ?? '',
|
||||
documentKey: this.kycForm.get('kycDocumentNumber')?.value || '',
|
||||
files
|
||||
};
|
||||
}
|
||||
|
||||
/** Load existing KYC data if clientId is set (edit mode) */
|
||||
private loadExistingKycIfNeeded(): void {
|
||||
if (!this.clientId || this.kycLoaded) return;
|
||||
@@ -469,6 +625,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
form15Type: data.form15Type || '15G',
|
||||
form15FY: data.form15FY || ''
|
||||
});
|
||||
// Sync chip-picker selection with the prefilled document name
|
||||
if (data.kycDocumentName) {
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
||||
}
|
||||
// Set verification statuses from existing data
|
||||
if (data.panVerified) {
|
||||
this.panVerifyStatus = 'VERIFIED';
|
||||
@@ -568,16 +728,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
|
||||
}
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({
|
||||
name,
|
||||
code: name,
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1232,6 +1394,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
STANDALONE_SHARED_IMPORTS
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user