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:
Aditya
2026-05-10 13:24:48 +05:30
parent 2d4fb4237e
commit 3b752dc6ae
56 changed files with 7741 additions and 599 deletions
@@ -216,7 +216,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
@@ -161,8 +161,13 @@ export class ClientFamilyMembersStepComponent implements OnInit {
panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.splice(index, 1, response.member);
if (!response?.member) return;
const updated = response.member;
this.clientFamilyMembers.splice(index, 1, updated);
// Persist to backend if this is an existing (already-saved) member
if (member.id && this.clientId) {
const { id, _linkedClient, _marriageCertFile, ...payload } = updated;
this.clientsService.editFamilyMember(this.clientId, member.id, payload).subscribe({ error: () => {} });
}
});
}
@@ -172,7 +177,15 @@ export class ClientFamilyMembersStepComponent implements OnInit {
data: { deleteContext: `${name}` }
});
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
if (!response?.delete) return;
const member = this.clientFamilyMembers[index];
// Persist deletion to backend for existing members; locally-added ones just drop from the array
if (member?.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, member.id.toString()).subscribe({
next: () => this.clientFamilyMembers.splice(index, 1),
error: () => this.clientFamilyMembers.splice(index, 1)
});
} else {
this.clientFamilyMembers.splice(index, 1);
}
});
@@ -228,7 +241,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
}
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.nomineeSearchResults = data?.content || [];
this.nomineeSearching = false;
@@ -324,7 +337,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({
this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
@@ -27,7 +27,7 @@ import {
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { Subject } from 'rxjs';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
@@ -340,6 +340,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsBlocked = false;
/** Screening log ID from sanctions API */
screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -500,6 +502,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) {
this.sanctionsVerified = true;
this.verifiedName = this.currentNameSnapshot();
} else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) {
this.sanctionsVerified = false;
}
@@ -701,6 +704,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
})
);
}
// Re-attach name-change watchers after controls are (re)created
setTimeout(() => this.watchNameChangesForReverification());
});
// Initialize legal form — use client's existing value in edit mode, default to PERSON for create
const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON;
@@ -901,20 +906,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
* If a match is found, sanctionsBlocked is set to true blocking further steps.
*/
verifySanctions() {
const firstname = this.createClientForm.get('firstname')?.value || '';
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
const name =
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ');
const name = this.currentNameSnapshot();
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px',
@@ -927,14 +919,62 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found
this.sanctionsVerified = true;
this.sanctionsBlocked = false;
this.verifiedName = name;
} else if (result.verified === false) {
// FLAGGED — match found, block account opening
this.sanctionsVerified = false;
this.sanctionsBlocked = true;
this.verifiedName = '';
}
});
}
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
return (
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ')
).trim();
}
/**
* Watch name fields — once a name has been verified, any edit invalidates the
* sanctions verification badge so the user must re-verify before submitting.
*/
private nameWatchSub: Subscription | null = null;
private watchNameChangesForReverification() {
this.nameWatchSub?.unsubscribe();
const fields = [
'firstname',
'middlename',
'lastname',
'fullname'
];
const controls = fields.map((f) => this.createClientForm.get(f)).filter((c) => !!c);
if (!controls.length) return;
this.nameWatchSub = merge(...controls.map((c) => c!.valueChanges))
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
}
});
}
/** Search existing clients to select as guardian */
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
@@ -947,7 +987,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({
this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
@@ -1106,7 +1146,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
this.signatorySearching = true;
this.signatorySearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({
this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.signatorySearchResults = data?.content || [];
this.signatorySearching = false;
@@ -1623,8 +1663,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value;
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const isEditMode = !!this.clientData;
for (const key in generalDetails) {
if (generalDetails[key] === '' || key === 'addSavings') {
if (key === 'addSavings') {
delete generalDetails[key];
continue;
}
if (generalDetails[key] === '') {
// In edit mode, preserve empty strings for fields that previously had a value so the
// backend can clear them (Fineract treats a missing field as "no change" but an empty
// string as a deliberate clear).
if (isEditMode && this.clientData[key] != null && this.clientData[key] !== '') {
continue;
}
delete generalDetails[key];
}
}
@@ -114,22 +114,165 @@
<div class="addr-divider"><span>Proof of Address</span></div>
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Address Proof Type</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>verified_user</mat-icon></span>
<mat-select required formControlName="proofType">
@for (pt of proofTypes; track pt) {
<mat-option [value]="pt">{{ pt }}</mat-option>
@if (loadingProofs) {
<div class="addr-proof-loading">
<fa-icon icon="spinner" [animation]="'spin'"></fa-icon>
Loading document types…
</div>
}
<div class="addr-proof-field">
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
<div class="addr-chip-wrap">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="addr-chip-grid">
@for (p of group.items; track p.id) {
<span
class="addr-chip"
[class.addr-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
role="button"
>
<mat-icon class="addr-chip__icon">
{{ isAddrProofSelected(p) ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="addr-chip-divider"></div>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Document Number</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
<input matInput formControlName="proofDocNumber" placeholder="e.g. XXXX XXXX XXXX" />
</mat-form-field>
}
</div>
</div>
@if (selectedAddrProof?.info) {
<div class="addr-proof-banner">
<mat-icon>check_circle</mat-icon>
<span>{{ selectedAddrProof.info }}</span>
</div>
}
@if (selectedAddrProof) {
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>{{ selectedAddrProof.documentKeyLabel || 'Document number' }}</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
<input
matInput
formControlName="documentKey"
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
/>
</mat-form-field>
@if (selectedAddrProof.requiresDate) {
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Issue / bill date</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
<input matInput type="date" formControlName="issueDate" />
</mat-form-field>
}
</div>
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title">
<mat-icon>image</mat-icon>
{{ selectedAddrProof.sides === 2 ? 'Front side' : selectedAddrProof.name + ' (single copy)' }}
</span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.front"
[class.addr-dropzone--req]="!addrFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back"
[class.addr-dropzone--req]="!addrFiles.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
}
</div>
@if (selectedAddrProof.sides === 2 && addrMergedPreview) {
<div class="addr-merged">
<div class="addr-merged__head">
<span class="addr-merged__title">
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="addr-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
</div>
<div class="addr-merged__preview">
<img [src]="addrMergedPreview" alt="Merged address proof" />
</div>
<div class="addr-merged__actions">
<button type="button" mat-stroked-button class="addr-merged__btn" (click)="reCropAddrFile('front')">
<mat-icon>crop</mat-icon> Re-crop front
</button>
<button type="button" mat-stroked-button class="addr-merged__btn" (click)="reCropAddrFile('back')">
<mat-icon>crop</mat-icon> Re-crop back
</button>
</div>
</div>
}
}
</div>
</mat-tab>
@@ -217,3 +217,346 @@ $muted: #64748b;
color: #1565c0;
}
}
/* ===================== Address-proof picker ===================== */
.addr-proof-loading {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #475569;
font-size: 13px;
margin-bottom: 14px;
}
.addr-proof-field {
margin: 4px 0 18px;
}
.addr-proof-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;
}
}
.addr-chip-wrap {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
background: #f8fafc;
}
.addr-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;
}
}
.addr-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.addr-chip-divider {
height: 1px;
background: #e2e8f0;
margin: 14px 0 10px;
}
.addr-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;
.addr-chip__icon {
color: #1976d2;
}
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
}
.addr-proof-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin: 12px 0 16px;
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;
}
}
.addr-upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 6px;
&--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 720px) {
grid-template-columns: 1fr !important;
}
}
.addr-upload-slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.addr-upload-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.addr-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;
}
}
.addr-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;
}
.addr-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;
}
}
}
.addr-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;
}
}
@@ -14,7 +14,10 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatButton } from '@angular/material/button';
import { ClientsService } from '../../clients.service';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
@Component({
selector: 'mifosx-client-india-address-step',
@@ -25,13 +28,16 @@ import { ClientsService } from '../../clients.service';
FaIconComponent,
MatCheckbox,
MatTabsModule,
MatIcon
MatIcon,
MatIconButton,
MatButton
]
})
export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private fb = inject(UntypedFormBuilder);
private http = inject(HttpClient);
private clientsService = inject(ClientsService);
private kycUpload = inject(KycUploadHelperService);
/** Client ID — when set, loads existing addresses for edit mode */
@Input() clientId: string = '';
@@ -66,11 +72,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
],
country: [{ value: 'India', disabled: true }],
landmark: [''],
proofType: [
proofId: [
'',
Validators.required
],
proofDocNumber: ['']
documentKey: [''],
issueDate: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
@@ -166,14 +173,15 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
correspondence: []
};
proofTypes = [
'Aadhaar Card',
'Passport',
'Voter ID',
'Driving Licence',
'Utility Bill (not older than 3 months)',
'Bank Statement'
];
/** KYC address-proof picker state (mirrors identities-tab dialog) */
addressProofs: ProofType[] = [];
addressGroups: { label: string; items: ProofType[] }[] = [];
selectedAddrProof: ProofType | null = null;
loadingProofs = false;
addrFiles: { front: File | null; back: File | null } = { front: null, back: null };
/** Combined front+back PNG (built automatically once both image sides are ready) */
addrMergedFile: File | null = null;
addrMergedPreview: string | null = null;
residenceTypes = [
'Owned',
@@ -206,11 +214,116 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private typeToTab: Record<string, string> = {};
ngOnInit(): void {
this.loadProofTypes();
if (this.clientId) {
this.loadExistingAddresses();
}
}
private loadProofTypes(): void {
this.loadingProofs = true;
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
},
error: () => {
this.loadingProofs = false;
}
});
}
selectAddrProof(proof: ProofType): void {
this.selectedAddrProof = proof;
this.permanentForm.get('proofId')?.setValue(proof.id);
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
isAddrProofSelected(proof: ProofType): boolean {
return this.selectedAddrProof?.id === proof.id;
}
async onAddrFile(event: Event, slot: 'front' | 'back'): Promise<void> {
const input = event.target as HTMLInputElement;
const raw = input.files?.[0];
input.value = '';
if (!raw) return;
if (raw.size > 5 * 1024 * 1024) return;
const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
if (!cropped) return;
this.addrFiles[slot] = cropped;
await this.refreshAddrMerged();
}
async reCropAddrFile(slot: 'front' | 'back'): Promise<void> {
const current = this.addrFiles[slot];
if (!current || !current.type.startsWith('image/')) return;
const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
if (!cropped) return;
this.addrFiles[slot] = cropped;
await this.refreshAddrMerged();
}
removeAddrFile(slot: 'front' | 'back'): void {
this.addrFiles[slot] = null;
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
private async refreshAddrMerged(): Promise<void> {
const proof = this.selectedAddrProof;
if (!proof || proof.sides !== 2 || !this.addrFiles.front || !this.addrFiles.back) {
this.addrMergedFile = null;
this.addrMergedPreview = null;
return;
}
const merged = await this.kycUpload.mergeFrontBack(
this.addrFiles.front,
this.addrFiles.back,
proof.name || 'address-proof'
);
if (merged) {
this.addrMergedFile = merged.file;
this.addrMergedPreview = merged.preview;
} else {
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
}
/**
* Files + proof metadata for the parent to persist (e.g. via client identifier API).
* When both image sides are present they're sent as a single combined PNG.
*/
get addressProofPayload(): {
proof: ProofType | null;
proofId: any;
documentKey: string;
issueDate: string;
files: { slot: string; file: File }[];
} {
const files: { slot: string; file: File }[] = [];
if (this.selectedAddrProof?.sides === 2 && this.addrMergedFile) {
files.push({ slot: 'Combined', file: this.addrMergedFile });
} else {
if (this.addrFiles.front) files.push({ slot: 'Front', file: this.addrFiles.front });
if (this.selectedAddrProof?.sides === 2 && this.addrFiles.back) {
files.push({ slot: 'Back', file: this.addrFiles.back });
}
}
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: this.permanentForm.get('issueDate')?.value || '',
files
};
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) {
const forms = [
@@ -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 &amp; 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 &amp; 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 &amp; 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
]
})