merge feature/update_code and feature/UI-MFA-Product branch code
This commit is contained in:
+1
-1
@@ -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;
|
||||
|
||||
+18
-5
@@ -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;
|
||||
|
||||
+69
-18
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
+157
-14
@@ -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>
|
||||
|
||||
|
||||
+343
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-11
@@ -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 & 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
|
||||
]
|
||||
})
|
||||
|
||||
+2
-2
@@ -180,13 +180,13 @@
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.ageYears != null) {
|
||||
@if (client?.ageYears !== null) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Age</span>
|
||||
<span class="preview-value">{{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d</span>
|
||||
</div>
|
||||
}
|
||||
@if (client?.isMinor != null) {
|
||||
@if (client?.isMinor !== null) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">Minor</span>
|
||||
<span class="preview-value">
|
||||
|
||||
@@ -77,6 +77,74 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- KYC Verification Status (Aadhaar + PAN) -->
|
||||
<div class="kyc-status-card" [class.kyc-status-card--complete]="kycComplete">
|
||||
<div class="kyc-status-card__head">
|
||||
<div class="kyc-status-card__title">
|
||||
<fa-icon [icon]="kycComplete ? 'shield-alt' : 'user-shield'" class="kyc-status-card__icon"></fa-icon>
|
||||
<span>KYC Verification</span>
|
||||
</div>
|
||||
@if (kycComplete) {
|
||||
<span class="kyc-status-card__badge kyc-status-card__badge--ok">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
KYC Done
|
||||
</span>
|
||||
} @else if (panVerified || aadhaarVerified) {
|
||||
<span class="kyc-status-card__badge kyc-status-card__badge--partial">
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
Partially Verified
|
||||
</span>
|
||||
} @else {
|
||||
<span class="kyc-status-card__badge kyc-status-card__badge--pending">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
Not Verified
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kyc-status-card__body">
|
||||
<div class="kyc-status-row">
|
||||
<div class="kyc-status-row__label">
|
||||
<fa-icon icon="id-card"></fa-icon>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
@if (panVerified) {
|
||||
<div class="kyc-status-row__value">
|
||||
<span class="kyc-status-row__id">{{ panNumber || '—' }}</span>
|
||||
@if (panHolderName) {
|
||||
<span class="kyc-status-row__name">{{ panHolderName }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="kyc-pill kyc-pill--ok"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kyc-status-row__value kyc-status-row__value--missing">{{ panNumber || 'Not provided' }}</span>
|
||||
<span class="kyc-pill kyc-pill--pending"><fa-icon icon="times-circle"></fa-icon> Pending</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kyc-status-row">
|
||||
<div class="kyc-status-row__label">
|
||||
<fa-icon icon="fingerprint"></fa-icon>
|
||||
<span>Aadhaar</span>
|
||||
</div>
|
||||
@if (aadhaarVerified) {
|
||||
<div class="kyc-status-row__value">
|
||||
<span class="kyc-status-row__id">{{ aadhaarMasked || '—' }}</span>
|
||||
@if (aadhaarHolderName) {
|
||||
<span class="kyc-status-row__name">{{ aadhaarHolderName }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="kyc-pill kyc-pill--ok"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kyc-status-row__value kyc-status-row__value--missing">{{
|
||||
aadhaarMasked || 'Not provided'
|
||||
}}</span>
|
||||
<span class="kyc-pill kyc-pill--pending"><fa-icon icon="times-circle"></fa-icon> Pending</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client basic info -->
|
||||
<div class="ckyc-info-grid">
|
||||
<div class="ckyc-info-item">
|
||||
|
||||
@@ -214,6 +214,179 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== KYC Verification status ===================== */
|
||||
.kyc-status-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
&--complete {
|
||||
border-color: #bbf7d0;
|
||||
background: linear-gradient(180deg, #f0fdf4 0%, #fff 60%);
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #2563eb;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&--complete &__icon {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--partial {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 6px 18px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #eef2f7;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
|
||||
fa-icon {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
&--missing {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__id {
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.kyc-status-row {
|
||||
grid-template-columns: 110px 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
.kyc-pill {
|
||||
grid-column: 2 / 3;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== Client info grid ===================== */
|
||||
.ckyc-info-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -67,6 +67,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
ckycBatchId = '';
|
||||
checkingExisting = false;
|
||||
|
||||
/** KYC verification status (Aadhaar + PAN) — independent of CERSAI sync */
|
||||
panVerified = false;
|
||||
aadhaarVerified = false;
|
||||
panHolderName = '';
|
||||
aadhaarHolderName = '';
|
||||
panNumber = '';
|
||||
aadhaarMasked = '';
|
||||
|
||||
get kycComplete(): boolean {
|
||||
return this.panVerified && this.aadhaarVerified;
|
||||
}
|
||||
|
||||
/** Pre-existing artefacts detected from client data */
|
||||
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
|
||||
ADDRESS_PROOF: { present: false },
|
||||
@@ -170,6 +182,13 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.ckycNumber = data?.ckycNumber || '';
|
||||
this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
|
||||
this.ckycBatchId = data?.ckycBatchId || '';
|
||||
this.panVerified = data?.panVerified === true || data?.panVerified === 'true' || data?.panVerified === 1;
|
||||
this.aadhaarVerified =
|
||||
data?.aadhaarVerified === true || data?.aadhaarVerified === 'true' || data?.aadhaarVerified === 1;
|
||||
this.panHolderName = data?.panFullName || data?.panHolderName || '';
|
||||
this.aadhaarHolderName = data?.aadhaarHolderName || '';
|
||||
this.panNumber = data?.panNumber || '';
|
||||
this.aadhaarMasked = data?.aadhaarNumberMasked || '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.mobileNo && clientViewData.dipVerified != null) {
|
||||
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">DIP Status</div>
|
||||
<div class="cv-cell__value">
|
||||
@@ -196,7 +196,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.nameVerified != null) {
|
||||
@if (clientViewData.nameVerified !== null) {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Sanctions Screening</div>
|
||||
<div class="cv-cell__value">
|
||||
@@ -236,13 +236,13 @@
|
||||
<div class="cv-cell__value">{{ clientViewData.maritalStatus.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
||||
@if (clientViewData.monthlyIncome !== null && clientViewData.monthlyIncome !== '') {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Monthly Income</div>
|
||||
<div class="cv-cell__value">₹ {{ clientViewData.monthlyIncome | number }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
||||
@if (clientViewData.annualTurnover !== null && clientViewData.annualTurnover !== '') {
|
||||
<div class="cv-cell">
|
||||
<div class="cv-cell__label">Annual Turnover</div>
|
||||
<div class="cv-cell__value">₹ {{ clientViewData.annualTurnover | number }}</div>
|
||||
|
||||
+4
-163
@@ -21,138 +21,10 @@ import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from '../../../clients.service';
|
||||
|
||||
interface ProofType {
|
||||
id: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
sides?: number;
|
||||
category?: string;
|
||||
documentKeyLabel?: string;
|
||||
documentKeyPlaceholder?: string;
|
||||
requiresDate?: boolean;
|
||||
info?: string;
|
||||
}
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
||||
|
||||
type SectionKey = 'id' | 'address';
|
||||
|
||||
/**
|
||||
* Client-side enrichment for KYC proof types. Backend currently returns
|
||||
* only { id, name, description (code), position, active, mandatory } — we
|
||||
* derive sides / category / banner text / input labels from the code.
|
||||
*/
|
||||
const PROOF_META: Record<string, Partial<ProofType>> = {
|
||||
PAN: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'PAN number',
|
||||
documentKeyPlaceholder: 'e.g. ABCDE1234F',
|
||||
info: 'PAN card — upload front side only (single copy required).'
|
||||
},
|
||||
AADHAAR: {
|
||||
sides: 2,
|
||||
documentKeyLabel: '12-digit Aadhaar number',
|
||||
documentKeyPlaceholder: 'XXXX XXXX XXXX',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Aadhaar — both front and back copies mandatory as per UIDAI guidelines.'
|
||||
},
|
||||
PASSPORT: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'Passport number',
|
||||
documentKeyPlaceholder: 'e.g. A1234567',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Passport — upload front (photo page) and back (last page with address).'
|
||||
},
|
||||
VOTER_ID: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'EPIC number',
|
||||
documentKeyPlaceholder: 'Voter ID number',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Voter ID (EPIC) — both sides required as address appears on the back.'
|
||||
},
|
||||
DRIVING_LICENSE: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'Driving licence number',
|
||||
documentKeyPlaceholder: 'DL number',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Driving licence — both sides required; vehicle class and validity are on the back.'
|
||||
},
|
||||
NREGA_JOB_CARD: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'NREGA job card number',
|
||||
documentKeyPlaceholder: 'Job card number',
|
||||
info: 'NREGA job card — ensure government authority signature is visible.'
|
||||
},
|
||||
NPR_LETTER: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'Reference / letter number',
|
||||
documentKeyPlaceholder: 'NPR reference number',
|
||||
info: 'NPR letter — upload full document.'
|
||||
},
|
||||
UTILITY_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Utility bill — must not be older than 3 months.'
|
||||
},
|
||||
ELECTRICITY_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Electricity bill — must not be older than 3 months.'
|
||||
},
|
||||
WATER_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Water bill — must not be older than 3 months.'
|
||||
},
|
||||
GAS_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Gas bill — must not be older than 3 months.'
|
||||
},
|
||||
INTERNET_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Internet bill — must not be older than 3 months.'
|
||||
},
|
||||
POSTPAID_MOBILE_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Postpaid mobile bill — must not be older than 3 months.'
|
||||
},
|
||||
BANK_STATEMENT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
info: 'Bank statement / passbook — not older than 3 months.'
|
||||
},
|
||||
RENT_AGREEMENT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Registered rent agreement — upload full document. Registration stamp must be visible.'
|
||||
},
|
||||
PROPERTY_TAX_RECEIPT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
info: 'Property tax receipt — latest receipt required.'
|
||||
},
|
||||
EMPLOYER_LETTER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Employer letter — must be on official letterhead with HR/management signature.'
|
||||
},
|
||||
PENSION_PAYMENT_ORDER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Pension payment order — upload full document issued by pension authority.'
|
||||
}
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-upload-document-dialog',
|
||||
templateUrl: './upload-document-dialog.component.html',
|
||||
@@ -245,9 +117,9 @@ export class UploadDocumentDialogComponent implements OnInit {
|
||||
this.clientsService.getKycProofTypes().subscribe({
|
||||
next: (res: any) => {
|
||||
const activeFilter = (p: any) => p?.active !== false;
|
||||
this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
|
||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
|
||||
this.buildAddressGroups();
|
||||
this.idProofs = (res?.idProofs || []).filter(activeFilter).map(normalizeProofType);
|
||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
this.loadingProofs = false;
|
||||
},
|
||||
error: () => {
|
||||
@@ -256,37 +128,6 @@ export class UploadDocumentDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeProof(p: any): ProofType {
|
||||
const code = (p.code || p.description || '').toString().toUpperCase();
|
||||
const meta = PROOF_META[code] || {};
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
code,
|
||||
sides: p.sides || meta.sides || 1,
|
||||
category: p.category || meta.category || 'Other',
|
||||
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
|
||||
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
|
||||
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
|
||||
info: p.info || meta.info || ''
|
||||
};
|
||||
}
|
||||
|
||||
private buildAddressGroups(): void {
|
||||
const map = new Map<string, ProofType[]>();
|
||||
this.addressProofs.forEach((p) => {
|
||||
const key = p.category || 'Other';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(p);
|
||||
});
|
||||
this.addressGroups = Array.from(map.entries()).map(
|
||||
([
|
||||
label,
|
||||
items
|
||||
]) => ({ label, items })
|
||||
);
|
||||
}
|
||||
|
||||
onIdProofChange(proofId: any): void {
|
||||
this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null;
|
||||
this.idFiles = { front: null, back: null };
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<div class="nom-field__value">{{ member.dateOfBirth | dateFormat }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (member.isDependent != null) {
|
||||
@if (member.isDependent !== null) {
|
||||
<div class="nom-field">
|
||||
<div class="nom-field__label">Dependent</div>
|
||||
<div class="nom-field__value">{{ member.isDependent | yesNo }}</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
@for (acc of selectedGroup.accounts; track acc.id) {
|
||||
<mat-option [value]="acc.id"
|
||||
>{{ acc.productName }} — {{ acc.accountNo }}
|
||||
@if (acc.accountBalance != null) {
|
||||
@if (acc.accountBalance !== null) {
|
||||
({{ acc.accountBalance | number: '1.2-2' }})
|
||||
}
|
||||
</mat-option>
|
||||
|
||||
@@ -294,7 +294,7 @@ export class NomineeTabComponent 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;
|
||||
@@ -412,7 +412,7 @@ export class NomineeTabComponent implements OnInit {
|
||||
}
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8).subscribe({
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
|
||||
next: (data: any) => {
|
||||
this.guardianSearchResults = data?.content || [];
|
||||
this.guardianSearching = false;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat: 'DD-MMMM-YYYY' }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.ageYears != null) {
|
||||
@if (clientViewData.ageYears !== null) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Age</div>
|
||||
<div class="pd-field__value">
|
||||
@@ -96,7 +96,7 @@
|
||||
<div class="pd-field__value">{{ clientViewData.socialCategory.name }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (clientViewData.isMinor != null) {
|
||||
@if (clientViewData.isMinor !== null) {
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Minor</div>
|
||||
<div class="pd-field__value">
|
||||
@@ -366,7 +366,7 @@
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Monthly Income</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
||||
@if (clientViewData.monthlyIncome !== null && clientViewData.monthlyIncome !== '') {
|
||||
<span class="monospace">₹ {{ clientViewData.monthlyIncome | number }}</span>
|
||||
} @else {
|
||||
-
|
||||
@@ -376,7 +376,7 @@
|
||||
<div class="pd-field">
|
||||
<div class="pd-field__label">Annual Turnover (Last FY)</div>
|
||||
<div class="pd-field__value">
|
||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
||||
@if (clientViewData.annualTurnover !== null && clientViewData.annualTurnover !== '') {
|
||||
<span class="monospace">₹ {{ clientViewData.annualTurnover | number }}</span>
|
||||
} @else {
|
||||
-
|
||||
|
||||
@@ -576,11 +576,20 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/collaterals/template`);
|
||||
}
|
||||
|
||||
searchByText(text: string, page: number, pageSize: number, sortAttribute: string = '', sortDirection: string = '') {
|
||||
searchByText(
|
||||
text: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortAttribute: string = '',
|
||||
sortDirection: string = '',
|
||||
activeOnly: boolean = false
|
||||
) {
|
||||
const innerRequest: any = { text };
|
||||
if (activeOnly) {
|
||||
innerRequest.activeOnly = true;
|
||||
}
|
||||
let request: any = {
|
||||
request: {
|
||||
text
|
||||
},
|
||||
request: innerRequest,
|
||||
page,
|
||||
size: pageSize
|
||||
};
|
||||
|
||||
@@ -72,9 +72,9 @@ export class CreateClientComponent {
|
||||
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
||||
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||
|
||||
datatables: any = [];
|
||||
@@ -250,6 +250,13 @@ export class CreateClientComponent {
|
||||
// KYC tab
|
||||
const kycTabIndex = 1;
|
||||
if (tabIndex === kycTabIndex) {
|
||||
// Photograph + Signature are mandatory before leaving the KYC tab
|
||||
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
||||
this.clientKycStep.markPhotoSigTouched();
|
||||
this.validationError = 'Photograph and Signature are required before continuing.';
|
||||
return;
|
||||
}
|
||||
this.validationError = '';
|
||||
// Save KYC
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
||||
@@ -265,6 +272,8 @@ export class CreateClientComponent {
|
||||
.uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(clientId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,6 +358,14 @@ export class CreateClientComponent {
|
||||
validationError = '';
|
||||
|
||||
submit() {
|
||||
// Validate: photograph and signature are mandatory
|
||||
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
||||
this.clientKycStep.markPhotoSigTouched();
|
||||
this.validationError = 'Photograph and Signature are required before submitting.';
|
||||
// Switch to KYC tab
|
||||
this.selectedTabIndex = 1;
|
||||
return;
|
||||
}
|
||||
// Validate: at least one family member required (Individual clients)
|
||||
if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) {
|
||||
this.validationError = 'At least one family member is required. Please add a family member before submitting.';
|
||||
@@ -425,6 +442,9 @@ export class CreateClientComponent {
|
||||
this.saveClientFamilyMembers(clientId);
|
||||
}
|
||||
|
||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(clientId);
|
||||
|
||||
// Save KYC only if it wasn't already created during the KYC step
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
@@ -514,6 +534,72 @@ export class CreateClientComponent {
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Persist the selected proof of address as a client identifier (with attached files)
|
||||
this.saveAddressProof(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the address-proof picked on the permanent tab as a client identifier
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'Address Proof';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the KYC document picked on the KYC tab as a client identifier
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
<div class="rekyc-alert">
|
||||
<mat-icon class="rekyc-alert__icon">warning</mat-icon>
|
||||
<div class="rekyc-alert__text">
|
||||
<strong>Re-KYC Required:</strong> Date of birth was changed. Please re-verify PAN and update the remarks
|
||||
below before proceeding.
|
||||
<strong>Re-KYC Required:</strong> Client name or date of birth was changed. Please re-verify PAN and
|
||||
update the remarks below before proceeding.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -180,6 +180,8 @@
|
||||
[addressData]="clientIndiaAddressStep?.addressData"
|
||||
[guardian]="clientGeneralStep?.selectedGuardian"
|
||||
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
|
||||
[entityClassificationData]="clientEntityKycStep?.classificationData"
|
||||
[familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
|
||||
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
|
||||
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
|
||||
[educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []"
|
||||
|
||||
@@ -69,9 +69,9 @@ export class EditClientComponent {
|
||||
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
||||
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||
|
||||
datatables: any = [];
|
||||
@@ -184,13 +184,13 @@ export class EditClientComponent {
|
||||
let dialogContext = 'Are you sure you want to save the changes';
|
||||
let dialogType = 'basic';
|
||||
|
||||
// Check if DOB changed on the General tab — warn about re-KYC
|
||||
// Check if name or DOB changed on the General tab — warn about re-KYC
|
||||
if (this.selectedTabIndex === 0) {
|
||||
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
if (changes.dobChanged) {
|
||||
if (changes.nameChanged || changes.dobChanged) {
|
||||
heading = 'Re-KYC Required';
|
||||
dialogContext =
|
||||
'Date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
|
||||
'Client name or date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
|
||||
dialogType = 'warn';
|
||||
}
|
||||
}
|
||||
@@ -232,11 +232,11 @@ export class EditClientComponent {
|
||||
// Log mobile/email changes as client notes for risk audit
|
||||
this.logFieldChangeNotes(changedFields);
|
||||
|
||||
// If DOB changed, flag re-KYC requirement and reset PAN verification
|
||||
if (changedFields.dobChanged) {
|
||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
||||
this.dobChanged = true;
|
||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
||||
this.flagReKycRequired(changedFields.oldDob, changedFields.newDob);
|
||||
this.flagReKycRequired(changedFields);
|
||||
}
|
||||
|
||||
// Update original data snapshot so subsequent edits compare against latest
|
||||
@@ -249,6 +249,13 @@ export class EditClientComponent {
|
||||
if (changedFields.dobChanged) {
|
||||
originalData.dateOfBirth = changedFields.newDob;
|
||||
}
|
||||
if (changedFields.nameChanged) {
|
||||
const form = this.clientGeneralForm;
|
||||
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
|
||||
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
|
||||
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
|
||||
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
|
||||
}
|
||||
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
},
|
||||
@@ -261,6 +268,11 @@ export class EditClientComponent {
|
||||
|
||||
// KYC tab (always index 1)
|
||||
if (tabIndex === 1) {
|
||||
// Photograph + Signature are mandatory before leaving the KYC tab
|
||||
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
||||
this.clientKycStep.markPhotoSigTouched();
|
||||
return;
|
||||
}
|
||||
this.savingClient = true;
|
||||
|
||||
// Upload photo if selected
|
||||
@@ -317,6 +329,8 @@ export class EditClientComponent {
|
||||
if (!kycId && res?.resourceId) {
|
||||
this.clientKycStep.kycId = res.resourceId.toString();
|
||||
}
|
||||
// Persist KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(this.clientId);
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
},
|
||||
error: () => {
|
||||
@@ -363,7 +377,7 @@ export class EditClientComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare current form values with original data to detect changes in DOB, mobile, email.
|
||||
* Compare current form values with original data to detect changes in name, DOB, mobile, email.
|
||||
*/
|
||||
private detectSensitiveFieldChanges(originalData: any) {
|
||||
const form = this.clientGeneralForm;
|
||||
@@ -381,10 +395,38 @@ export class EditClientComponent {
|
||||
? oldDobRaw.toISOString().split('T')[0]
|
||||
: oldDobRaw || '';
|
||||
|
||||
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
||||
(
|
||||
parts.fullname ||
|
||||
[
|
||||
parts.firstname,
|
||||
parts.middlename,
|
||||
parts.lastname
|
||||
]
|
||||
.filter((p) => p && p.trim())
|
||||
.join(' ')
|
||||
).trim();
|
||||
|
||||
const newName = buildName({
|
||||
firstname: form?.get('firstname')?.value || '',
|
||||
middlename: form?.get('middlename')?.value || '',
|
||||
lastname: form?.get('lastname')?.value || '',
|
||||
fullname: form?.get('fullname')?.value || ''
|
||||
});
|
||||
const oldName = buildName({
|
||||
firstname: originalData.firstname || '',
|
||||
middlename: originalData.middlename || '',
|
||||
lastname: originalData.lastname || '',
|
||||
fullname: originalData.fullname || ''
|
||||
});
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
mobileChanged: newMobile !== oldMobile,
|
||||
emailChanged: newEmail !== oldEmail,
|
||||
dobChanged: newDob !== oldDob,
|
||||
oldName,
|
||||
newName,
|
||||
oldMobile,
|
||||
newMobile,
|
||||
oldEmail,
|
||||
@@ -412,22 +454,38 @@ export class EditClientComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* When DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
|
||||
* When name or DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
|
||||
*/
|
||||
private flagReKycRequired(oldDob: string, newDob: string) {
|
||||
private flagReKycRequired(changes: {
|
||||
nameChanged: boolean;
|
||||
dobChanged: boolean;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
oldDob: string;
|
||||
newDob: string;
|
||||
}) {
|
||||
const reasons: string[] = [];
|
||||
if (changes.nameChanged) {
|
||||
reasons.push(`Client name changed from "${changes.oldName}" to "${changes.newName}"`);
|
||||
}
|
||||
if (changes.dobChanged) {
|
||||
reasons.push(`Date of birth changed from "${changes.oldDob}" to "${changes.newDob}"`);
|
||||
}
|
||||
const reasonText = reasons.join('; ');
|
||||
|
||||
// Reset PAN verification status so user must re-verify
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.panVerifyStatus = 'NONE';
|
||||
this.clientKycStep.panVerifyName = '';
|
||||
this.clientKycStep.panVerifiedOn = '';
|
||||
// Pre-fill remarks with re-KYC note
|
||||
const remarkText = `[Re-KYC Required] Date of birth changed from ${oldDob} to ${newDob}. PAN re-verification is mandatory.`;
|
||||
const remarkText = `[Re-KYC Required] ${reasonText}. PAN re-verification is mandatory.`;
|
||||
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
|
||||
}
|
||||
|
||||
// Also log DOB change as an audit note
|
||||
// Also log the change as an audit note
|
||||
const timestamp = new Date().toISOString();
|
||||
const note = `[AUDIT] Date of birth changed from "${oldDob}" to "${newDob}" on ${timestamp}. Re-KYC (PAN re-verification) required.`;
|
||||
const note = `[AUDIT] ${reasonText} on ${timestamp}. Re-KYC (PAN re-verification) required.`;
|
||||
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
@@ -439,6 +497,13 @@ export class EditClientComponent {
|
||||
* Submits the edit client form.
|
||||
*/
|
||||
submit() {
|
||||
// Photograph + Signature are mandatory before submitting
|
||||
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
|
||||
this.clientKycStep.markPhotoSigTouched();
|
||||
this.selectedTabIndex = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const clientData = {
|
||||
@@ -496,10 +561,14 @@ export class EditClientComponent {
|
||||
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
|
||||
}
|
||||
kycObs.subscribe({
|
||||
next: () => navigateBack(),
|
||||
next: () => {
|
||||
this.saveKycDocProof(this.clientId);
|
||||
navigateBack();
|
||||
},
|
||||
error: () => navigateBack()
|
||||
});
|
||||
} else {
|
||||
this.saveKycDocProof(this.clientId);
|
||||
navigateBack();
|
||||
}
|
||||
},
|
||||
@@ -580,6 +649,72 @@ export class EditClientComponent {
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
// Persist the selected proof of address as a client identifier (with attached files)
|
||||
this.saveAddressProof(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the address-proof picked on the permanent tab as a client identifier
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'Address Proof';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the KYC document picked on the KYC tab as a client identifier
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
const identifierData: any = {
|
||||
documentTypeId: payload.proofId,
|
||||
status: 'Active',
|
||||
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
|
||||
description: 'KYC document'
|
||||
};
|
||||
|
||||
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
private saveClientFamilyMembers(clientId: string) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
export interface ProofType {
|
||||
id: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
sides?: number;
|
||||
category?: string;
|
||||
documentKeyLabel?: string;
|
||||
documentKeyPlaceholder?: string;
|
||||
requiresDate?: boolean;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side enrichment for KYC proof types. Backend currently returns
|
||||
* only { id, name, description (code), position, active, mandatory } — we
|
||||
* derive sides / category / banner text / input labels from the code.
|
||||
*/
|
||||
export const PROOF_META: Record<string, Partial<ProofType>> = {
|
||||
PAN: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'PAN number',
|
||||
documentKeyPlaceholder: 'e.g. ABCDE1234F',
|
||||
info: 'PAN card — upload front side only (single copy required).'
|
||||
},
|
||||
AADHAAR: {
|
||||
sides: 2,
|
||||
documentKeyLabel: '12-digit Aadhaar number',
|
||||
documentKeyPlaceholder: 'XXXX XXXX XXXX',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Aadhaar — both front and back copies mandatory as per UIDAI guidelines.'
|
||||
},
|
||||
PASSPORT: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'Passport number',
|
||||
documentKeyPlaceholder: 'e.g. A1234567',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Passport — upload front (photo page) and back (last page with address).'
|
||||
},
|
||||
VOTER_ID: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'EPIC number',
|
||||
documentKeyPlaceholder: 'Voter ID number',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Voter ID (EPIC) — both sides required as address appears on the back.'
|
||||
},
|
||||
DRIVING_LICENSE: {
|
||||
sides: 2,
|
||||
documentKeyLabel: 'Driving licence number',
|
||||
documentKeyPlaceholder: 'DL number',
|
||||
category: 'Government-issued ID',
|
||||
info: 'Driving licence — both sides required; vehicle class and validity are on the back.'
|
||||
},
|
||||
NREGA_JOB_CARD: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'NREGA job card number',
|
||||
documentKeyPlaceholder: 'Job card number',
|
||||
info: 'NREGA job card — ensure government authority signature is visible.'
|
||||
},
|
||||
NPR_LETTER: {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'Reference / letter number',
|
||||
documentKeyPlaceholder: 'NPR reference number',
|
||||
info: 'NPR letter — upload full document.'
|
||||
},
|
||||
UTILITY_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Utility bill — must not be older than 3 months.'
|
||||
},
|
||||
ELECTRICITY_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Electricity bill — must not be older than 3 months.'
|
||||
},
|
||||
WATER_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Water bill — must not be older than 3 months.'
|
||||
},
|
||||
GAS_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Gas bill — must not be older than 3 months.'
|
||||
},
|
||||
INTERNET_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Internet bill — must not be older than 3 months.'
|
||||
},
|
||||
POSTPAID_MOBILE_BILL: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
requiresDate: true,
|
||||
info: 'Postpaid mobile bill — must not be older than 3 months.'
|
||||
},
|
||||
BANK_STATEMENT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
info: 'Bank statement / passbook — not older than 3 months.'
|
||||
},
|
||||
RENT_AGREEMENT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Registered rent agreement — upload full document. Registration stamp must be visible.'
|
||||
},
|
||||
PROPERTY_TAX_RECEIPT: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
info: 'Property tax receipt — latest receipt required.'
|
||||
},
|
||||
EMPLOYER_LETTER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Employer letter — must be on official letterhead with HR/management signature.'
|
||||
},
|
||||
PENSION_PAYMENT_ORDER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
info: 'Pension payment order — upload full document issued by pension authority.'
|
||||
}
|
||||
};
|
||||
|
||||
export function normalizeProofType(p: any): ProofType {
|
||||
const code = (p.code || p.description || '').toString().toUpperCase();
|
||||
const meta = PROOF_META[code] || {};
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
code,
|
||||
sides: p.sides || meta.sides || 1,
|
||||
category: p.category || meta.category || 'Other',
|
||||
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
|
||||
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
|
||||
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
|
||||
info: p.info || meta.info || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function groupProofsByCategory(proofs: ProofType[]): { label: string; items: ProofType[] }[] {
|
||||
const map = new Map<string, ProofType[]>();
|
||||
proofs.forEach((p) => {
|
||||
const key = p.category || 'Other';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(p);
|
||||
});
|
||||
return Array.from(map.entries()).map(
|
||||
([
|
||||
label,
|
||||
items
|
||||
]) => ({ label, items })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ImageCropperDialogComponent } from '../clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KycUploadHelperService {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/**
|
||||
* If the picked file is an image, open the cropper dialog and resolve with
|
||||
* the cropped File (or null if cancelled). Non-image files (PDFs) are
|
||||
* passed through unchanged.
|
||||
*/
|
||||
pickWithCrop(file: File, title: string): Promise<File | null> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return Promise.resolve(file);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const ref = this.dialog.open(ImageCropperDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
data: { file, title }
|
||||
});
|
||||
ref.afterClosed().subscribe((cropped: File | null) => resolve(cropped || null));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack two images (front above back) into one PNG. Resolves with both
|
||||
* the new File (suitable for upload) and a data-URL preview for the UI.
|
||||
* Returns null if either side is not an image (e.g. one is a PDF).
|
||||
*/
|
||||
mergeFrontBack(front: File, back: File, name: string): Promise<{ file: File; preview: string } | null> {
|
||||
if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.all([
|
||||
this.fileToImage(front),
|
||||
this.fileToImage(back)
|
||||
]).then(
|
||||
([
|
||||
fImg,
|
||||
bImg
|
||||
]) => {
|
||||
const targetWidth = Math.max(fImg.width, bImg.width);
|
||||
const fScale = targetWidth / fImg.width;
|
||||
const bScale = targetWidth / bImg.width;
|
||||
const fH = fImg.height * fScale;
|
||||
const bH = bImg.height * bScale;
|
||||
const gap = 20;
|
||||
const totalH = Math.round(fH + bH + gap);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = totalH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
|
||||
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
|
||||
|
||||
return new Promise<{ file: File; preview: string } | null>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], `${name}-combined.png`, { type: 'image/png' });
|
||||
const preview = canvas.toDataURL('image/png');
|
||||
resolve({ file, preview });
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fileToImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('image load failed'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user