feat: Client & CKYC UI improvements — filters, KYC management, image crop+merge
- Clients list: CKYC icon, CKYC Number column, status/legal form/CKYC filters - CKYC: SFTP upload logs, date formatting, modal z-index fix, non-dismissible dialogs - KYC Management: new standalone module with alert dashboard, notification scheduler, audit inventory, account status - Image cropper: ngx-image-cropper integration for front+back document merge into single PNG - Sidebar: KYC Management menu entry after CKYC Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
79e455c4ed
commit
d72e969dc3
+6
-6
@@ -41,11 +41,11 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px 16px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: rgb(0 0 0 / 45%);
|
||||
font-size: 14px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
background: rgb(0 0 0 / 2%);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||
border: 1px dashed rgb(0 0 0 / 12%);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 18px;
|
||||
@@ -58,7 +58,7 @@
|
||||
.panel-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
+5
-3
@@ -12,7 +12,7 @@
|
||||
&__left { display: flex; align-items: center; gap: 12px; }
|
||||
&__icon { font-size: 18px; color: #60a5fa; }
|
||||
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
|
||||
&__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; }
|
||||
&__sub { font-size: 11px; color: rgb(255 255 255 / 50%); margin: 2px 0 0; }
|
||||
}
|
||||
|
||||
.ec-chips-label {
|
||||
@@ -133,6 +133,7 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-checkbox {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -197,8 +198,9 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
@media (width <= 600px) {
|
||||
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
|
||||
.ec-check-grid { grid-template-columns: 1fr; }
|
||||
.ec-card-head { padding: 12px 14px; }
|
||||
|
||||
+8
-2
@@ -55,7 +55,7 @@
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -113,6 +113,7 @@
|
||||
|
||||
&__left { flex-shrink: 0; }
|
||||
&__icon { color: #1074b9; }
|
||||
|
||||
&__photo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -123,6 +124,7 @@
|
||||
&__info { flex: 1; display: flex; flex-direction: column; }
|
||||
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
|
||||
|
||||
&__sig {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -139,6 +141,7 @@
|
||||
background: #fff;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
@@ -225,6 +228,7 @@
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.not-verified {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
@@ -266,7 +270,7 @@
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +313,7 @@
|
||||
width: 100%;
|
||||
|
||||
&__icon { color: #1074b9; font-size: 20px; }
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
@@ -318,6 +323,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
color: #94a3b8 !important;
|
||||
width: 28px !important;
|
||||
|
||||
+8
-5
@@ -1,7 +1,7 @@
|
||||
.zoomable {
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
|
||||
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgb(0 0 0 / 18%); }
|
||||
}
|
||||
|
||||
::ng-deep .image-zoom-panel {
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@
|
||||
&--delete:hover mat-icon { color: #ef4444; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.fm-step__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -358,6 +358,7 @@
|
||||
.nom-card__info { flex: 1; }
|
||||
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
|
||||
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
|
||||
|
||||
.nom-card__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
|
||||
@@ -445,6 +446,7 @@
|
||||
/* Search */
|
||||
.nom-search-wrapper { position: relative; }
|
||||
.nom-search-field { width: 100%; }
|
||||
|
||||
.nom-search-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -460,7 +462,7 @@
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: -12px;
|
||||
@@ -517,6 +519,7 @@
|
||||
.nom-selected__info { flex: 1; }
|
||||
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
|
||||
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
|
||||
|
||||
.nom-selected__sig {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
|
||||
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
|
||||
@@ -599,7 +602,7 @@
|
||||
button { display: flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.nom-form-fields { grid-template-columns: 1fr; }
|
||||
.nom-guardian-card { flex-wrap: wrap; }
|
||||
.nom-guardian-rel { width: 100%; }
|
||||
|
||||
@@ -619,6 +619,7 @@
|
||||
<span class="sig-table__col sig-table__col--name">Name</span>
|
||||
<span class="sig-table__col sig-table__col--id">Client ID</span>
|
||||
<span class="sig-table__col sig-table__col--pan">PAN</span>
|
||||
<span class="sig-table__col sig-table__col--din">DIN</span>
|
||||
<span class="sig-table__col sig-table__col--status">Status</span>
|
||||
<span class="sig-table__col sig-table__col--actions">Actions</span>
|
||||
</div>
|
||||
@@ -645,6 +646,17 @@
|
||||
{{ sig.panNumber || '-' }}
|
||||
@if (sig.panVerified) { <mat-icon class="verify-tick-sm">check_circle</mat-icon> }
|
||||
</span>
|
||||
<span class="sig-table__col sig-table__col--din">
|
||||
@if (sig.dinNumber) {
|
||||
{{ sig.dinNumber }}
|
||||
@if (sig.dinVerified) { <mat-icon class="verify-tick-sm">check_circle</mat-icon> }
|
||||
@if (!sig.dinVerified) { <mat-icon class="verify-cross-sm">cancel</mat-icon> }
|
||||
} @else {
|
||||
<button mat-stroked-button class="din-verify-btn" type="button" matTooltip="Verify DIN" (click)="openDinVerifyDialog(sig)">
|
||||
<mat-icon class="din-verify-icon">badge</mat-icon> Verify DIN
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
<span class="sig-table__col sig-table__col--status">
|
||||
<span class="sig-status-chip" [class.active]="sig.status?.value === 'Active'">{{ sig.status?.value || '-' }}</span>
|
||||
</span>
|
||||
|
||||
+47
-14
@@ -13,7 +13,7 @@ form {
|
||||
.section-block {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -28,7 +28,7 @@ form {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0 0 16px;
|
||||
|
||||
.section-icon {
|
||||
color: var(--md-sys-color-primary, #1074b9);
|
||||
@@ -235,7 +235,7 @@ form {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.guardian-section__header {
|
||||
@@ -268,7 +268,7 @@ form {
|
||||
|
||||
.guardian-section__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: rgb(255 255 255 / 55%);
|
||||
}
|
||||
|
||||
.guardian-section__age-chips {
|
||||
@@ -280,9 +280,9 @@ form {
|
||||
.guardian-age-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: rgb(255 255 255 / 80%);
|
||||
padding: 3px 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 10px;
|
||||
|
||||
strong {
|
||||
@@ -638,12 +638,13 @@ form {
|
||||
padding: 0 !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.mdc-dialog__surface {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.guardian-section__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -738,7 +739,7 @@ form {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -834,7 +835,7 @@ form {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
.guardian-card__header {
|
||||
@@ -945,7 +946,7 @@ form {
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 10%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1030,7 +1031,7 @@ form {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgb(255 255 255 / 15%);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
@@ -1040,7 +1041,7 @@ form {
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: rgb(255 255 255 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,6 +1202,7 @@ form {
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #10b981 !important;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #059669 !important;
|
||||
-webkit-text-fill-color: #059669 !important;
|
||||
@@ -1213,6 +1215,7 @@ form {
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #0ea5e9 !important;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #0284c7 !important;
|
||||
-webkit-text-fill-color: #0284c7 !important;
|
||||
@@ -1225,6 +1228,7 @@ form {
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: #f59e0b !important;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #d97706 !important;
|
||||
-webkit-text-fill-color: #d97706 !important;
|
||||
@@ -1390,7 +1394,7 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1505,6 +1509,7 @@ form {
|
||||
&--name { flex: 1; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
&--id { flex: 0 0 100px; font-size: 12px; color: #64748b; }
|
||||
&--pan { flex: 0 0 120px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
|
||||
&--din { flex: 0 0 140px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
|
||||
&--status { flex: 0 0 80px; }
|
||||
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
|
||||
}
|
||||
@@ -1534,6 +1539,32 @@ form {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.verify-cross-sm {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.din-verify-btn {
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
min-height: 24px !important;
|
||||
line-height: 20px !important;
|
||||
border-radius: 6px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #1074b9 !important;
|
||||
border-color: #93c5fd !important;
|
||||
|
||||
.din-verify-icon {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sig-status-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -1561,11 +1592,13 @@ form {
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.mdc-dialog__surface {
|
||||
border-radius: 12px !important;
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
@@ -1625,7 +1658,7 @@ form {
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
||||
|
||||
/**
|
||||
* Create Client Component
|
||||
@@ -824,6 +825,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.syncSignatoryNames();
|
||||
|
||||
this.fetchClientFullDetails(client.id, sig);
|
||||
this.openDinVerifyDialog(sig);
|
||||
}
|
||||
|
||||
/** Open DIN verification dialog for a signatory */
|
||||
openDinVerifyDialog(sig: any): void {
|
||||
const cid = sig.id?.toString();
|
||||
if (!cid) return;
|
||||
|
||||
const entityName = this.createClientForm.get('fullname')?.value || '';
|
||||
|
||||
const openDialog = (kycId: string, panFromKyc?: string) => {
|
||||
const ref = this.dialog.open(DinVerifyDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: false,
|
||||
autoFocus: true,
|
||||
data: {
|
||||
clientId: cid,
|
||||
kycId,
|
||||
signatoryName: sig.displayName || '',
|
||||
cifNumber: sig.cifNumber || sig.accountNumber || cid,
|
||||
panNumber: sig.panNumber || panFromKyc || '-',
|
||||
entityName
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe((result: any) => {
|
||||
if (result && !result.skipped) {
|
||||
sig.dinNumber = result.dinNumber || '';
|
||||
sig.dinVerified = result.dinVerified || false;
|
||||
sig.directorName = result.directorName || '';
|
||||
sig.dinStatus = result.dinStatus || '';
|
||||
sig.dinDesignation = result.dinDesignation || '';
|
||||
sig.companyMatch = result.companyMatch ?? null;
|
||||
sig.matchedCompany = result.matchedCompany || '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch KYC ID first, then open dialog
|
||||
this.clientService.getClientKyc(cid).subscribe({
|
||||
next: (kyc: any) => openDialog(kyc?.id?.toString() || '', kyc?.panNumber),
|
||||
error: () => openDialog('')
|
||||
});
|
||||
}
|
||||
|
||||
removeSignatoryAt(index: number): void {
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-din-verify-dialog',
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
<fa-icon icon="id-badge" class="dialog-title-icon"></fa-icon>
|
||||
DIN Verification
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<!-- Step 1: Enter DIN -->
|
||||
@if (!verifying && !verified && !failed && !errorOccurred) {
|
||||
<div class="din-entry">
|
||||
<div class="signatory-info">
|
||||
<fa-icon icon="user-shield" class="sig-icon"></fa-icon>
|
||||
<div class="sig-detail">
|
||||
<span class="sig-name">{{ signatoryName }}</span>
|
||||
<span class="sig-meta">CIF: {{ cifNumber }} · PAN: {{ panNumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (entityName) {
|
||||
<div class="entity-info">
|
||||
<fa-icon icon="building" class="entity-icon"></fa-icon>
|
||||
<div class="entity-detail">
|
||||
<span class="entity-label">Entity</span>
|
||||
<span class="entity-name">{{ entityName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="din-info-bar">
|
||||
<fa-icon icon="info-circle" class="info-icon"></fa-icon>
|
||||
<span>Enter the <strong>Director Identification Number (DIN)</strong> issued by the Ministry of Corporate Affairs (MCA) for this authorized signatory. The system will verify the DIN and match the entity name against the director's associated companies.</span>
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="din-input-field">
|
||||
<mat-label>DIN Number</mat-label>
|
||||
<mat-icon matPrefix class="din-prefix-icon">badge</mat-icon>
|
||||
<input matInput [(ngModel)]="dinNumber" placeholder="e.g. 01234567" maxlength="8"
|
||||
(keyup.enter)="onVerify()" autocomplete="off" />
|
||||
<mat-hint>8-digit DIN number from MCA</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 2: Verifying -->
|
||||
@if (verifying) {
|
||||
<div class="progress-section">
|
||||
<mat-progress-bar mode="determinate" [value]="progress" color="primary"></mat-progress-bar>
|
||||
<div class="progress-percent">{{ progress }}%</div>
|
||||
<div class="progress-steps">
|
||||
@for (step of progressSteps; track step.label; let i = $index) {
|
||||
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
|
||||
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
|
||||
<span>{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="verify-msg">
|
||||
Verifying DIN <strong>{{ dinNumber }}</strong> for <strong>{{ signatoryName }}</strong>
|
||||
against <strong>MCA (Ministry of Corporate Affairs)</strong> records.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3a: Verified -->
|
||||
@if (!verifying && verified) {
|
||||
<div class="result-card success-card">
|
||||
<div class="result-icon-ring success-ring">
|
||||
<fa-icon icon="check-circle" class="result-shield success-shield"></fa-icon>
|
||||
</div>
|
||||
<div class="result-badge success-badge">VERIFIED</div>
|
||||
<h3 class="result-title">DIN Verified Successfully</h3>
|
||||
<p class="result-detail">
|
||||
DIN <strong class="din-highlight">{{ dinNumber }}</strong> for
|
||||
<strong>{{ signatoryName }}</strong> has been verified against
|
||||
<strong>MCA</strong> records.
|
||||
</p>
|
||||
<div class="result-meta">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">DIN</span>
|
||||
<span class="meta-value meta-mono">{{ dinNumber }}</span>
|
||||
</div>
|
||||
@if (directorName) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Director Name</span>
|
||||
<span class="meta-value">{{ directorName }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Status</span>
|
||||
<span class="meta-value meta-success">{{ dinStatus || 'Active' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Verified On</span>
|
||||
<span class="meta-value">{{ verifiedDate }}</span>
|
||||
</div>
|
||||
@if (dinDesignation) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Designation</span>
|
||||
<span class="meta-value">{{ dinDesignation }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Company Match Section -->
|
||||
@if (companies.length > 0) {
|
||||
<div class="company-match-section">
|
||||
<h4 class="company-match-title">
|
||||
<fa-icon icon="building" class="company-title-icon"></fa-icon>
|
||||
Associated Companies ({{ companies.length }})
|
||||
</h4>
|
||||
|
||||
@if (companyMatch) {
|
||||
<div class="match-banner match-success">
|
||||
<fa-icon icon="check-circle" class="match-banner-icon"></fa-icon>
|
||||
<div>
|
||||
<strong>Entity Match Found</strong>
|
||||
<p><strong>{{ entityName }}</strong> matches <strong>{{ matchedCompany }}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="match-banner match-warning">
|
||||
<fa-icon icon="exclamation-triangle" class="match-banner-icon"></fa-icon>
|
||||
<div>
|
||||
<strong>No Entity Match</strong>
|
||||
<p><strong>{{ entityName }}</strong> does not match any of the director's associated companies.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="company-list">
|
||||
@for (company of companies; track company.companyName || $index) {
|
||||
<div class="company-item" [class.company-matched]="company._matched">
|
||||
@if (company._matched) {
|
||||
<mat-icon class="company-match-icon match">check_circle</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="company-match-icon no-match">radio_button_unchecked</mat-icon>
|
||||
}
|
||||
<div class="company-info">
|
||||
<span class="company-name">{{ company.companyName }}</span>
|
||||
<span class="company-meta">
|
||||
@if (company.cin) { CIN: {{ company.cin }} }
|
||||
@if (company.cin && company.designation) { · }
|
||||
@if (company.designation) { {{ company.designation }} }
|
||||
@if ((company.cin || company.designation) && company.appointmentDate) { · }
|
||||
@if (company.appointmentDate) { Since: {{ company.appointmentDate }} }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3b: Verification Failed -->
|
||||
@if (!verifying && failed) {
|
||||
<div class="result-card fail-card">
|
||||
<div class="result-icon-ring fail-ring">
|
||||
<fa-icon icon="times-circle" class="result-shield fail-shield"></fa-icon>
|
||||
</div>
|
||||
<div class="result-badge fail-badge">FAILED</div>
|
||||
<h3 class="result-title">DIN Verification Failed</h3>
|
||||
<p class="result-detail">
|
||||
DIN <strong class="din-highlight-fail">{{ dinNumber }}</strong> could not be verified.
|
||||
</p>
|
||||
@if (failReason) {
|
||||
<div class="fail-reason">
|
||||
<fa-icon icon="exclamation-triangle" class="fail-reason-icon"></fa-icon>
|
||||
<span>{{ failReason }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3c: Error -->
|
||||
@if (!verifying && errorOccurred) {
|
||||
<div class="result-card error-card">
|
||||
<fa-icon icon="exclamation-circle" class="error-icon-large"></fa-icon>
|
||||
<p class="error-msg">Verification failed. Please try again.</p>
|
||||
<p class="error-detail">{{ errorMessage }}</p>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!verifying && !verified && !failed && !errorOccurred) {
|
||||
<button mat-stroked-button (click)="onSkip()">Skip</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!dinNumber || dinNumber.length < 8" (click)="onVerify()">
|
||||
<fa-icon icon="search"></fa-icon> Verify DIN
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && verified) {
|
||||
<button mat-raised-button color="primary" (click)="onClose(true)">
|
||||
<fa-icon icon="check"></fa-icon> Done
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && failed) {
|
||||
<button mat-stroked-button (click)="onRetry()">Retry</button>
|
||||
<button mat-raised-button color="warn" (click)="onClose(false)">
|
||||
<fa-icon icon="times"></fa-icon> Close
|
||||
</button>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
<button mat-stroked-button (click)="onRetry()">Retry</button>
|
||||
<button mat-raised-button (click)="onClose(false)">Close</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
h2[mat-dialog-title] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 16px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.dialog-title-icon { font-size: 20px; }
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 16px 24px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Signatory info */
|
||||
.signatory-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #f0f4f8;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.sig-icon { font-size: 22px; color: #1074b9; }
|
||||
.sig-detail { display: flex; flex-direction: column; }
|
||||
.sig-name { font-size: 15px; font-weight: 700; color: #1a2332; }
|
||||
.sig-meta { font-size: 12px; color: #64748b; margin-top: 2px; }
|
||||
|
||||
/* Entity info */
|
||||
.entity-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.entity-icon { font-size: 18px; color: #b45309; }
|
||||
.entity-detail { display: flex; flex-direction: column; }
|
||||
.entity-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #92400e; }
|
||||
.entity-name { font-size: 14px; font-weight: 700; color: #78350f; }
|
||||
|
||||
/* DIN info bar */
|
||||
.din-info-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-icon { font-size: 16px; color: #1074b9; flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
/* DIN input */
|
||||
.din-input-field { width: 100%; }
|
||||
.din-prefix-icon { color: #64748b; margin-right: 4px; }
|
||||
|
||||
/* Progress */
|
||||
.progress-section { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.progress-percent { font-size: 22px; font-weight: 700; color: #1074b9; margin-top: 4px; }
|
||||
|
||||
.progress-steps { display: flex; flex-direction: column; gap: 10px; width: 100%; padding: 8px 16px; }
|
||||
.progress-step { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #9ca3af; transition: color 0.3s ease; }
|
||||
.progress-step.active { color: #1074b9; font-weight: 600; }
|
||||
.progress-step.done { color: #388e3c; }
|
||||
.done-icon { color: #388e3c; }
|
||||
|
||||
.verify-msg { font-size: 14px; color: #555; line-height: 1.6; text-align: center; margin: 4px 0 0; }
|
||||
|
||||
/* Result cards */
|
||||
.result-card { display: flex; flex-direction: column; align-items: center; padding: 20px 16px; gap: 12px; }
|
||||
.result-icon-ring { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.success-ring { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border: 3px solid #a7f3d0; }
|
||||
.fail-ring { background: linear-gradient(135deg, #fef2f2, #fecaca); border: 3px solid #fca5a5; }
|
||||
.result-shield { font-size: 28px; }
|
||||
.success-shield { color: #059669; }
|
||||
.fail-shield { color: #dc2626; }
|
||||
|
||||
.result-badge { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 1.5px; padding: 3px 14px; border-radius: 20px; }
|
||||
.success-badge { color: #059669; background: #ecfdf5; border: 1px solid #a7f3d0; }
|
||||
.fail-badge { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
|
||||
|
||||
.result-title { font-size: 17px; font-weight: 700; color: #111827; margin: 0; }
|
||||
.result-detail { font-size: 13px; color: #6b7280; text-align: center; line-height: 1.6; margin: 0; max-width: 400px; }
|
||||
.din-highlight { color: #059669; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||
.din-highlight-fail { color: #dc2626; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||
|
||||
.result-meta { width: 100%; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-top: 4px; }
|
||||
.meta-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f3f4f6; }
|
||||
.meta-row:last-child { border-bottom: none; }
|
||||
.meta-label { font-size: 12px; color: #9ca3af; font-weight: 500; }
|
||||
.meta-value { font-size: 12px; color: #374151; font-weight: 600; }
|
||||
.meta-mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; letter-spacing: 0.5px; }
|
||||
.meta-success { color: #059669; }
|
||||
|
||||
/* ═══ Company Match Section ═══ */
|
||||
.company-match-section {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.company-match-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.company-title-icon { font-size: 14px; color: #64748b; }
|
||||
|
||||
/* Match banner */
|
||||
.match-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.match-banner p { margin: 2px 0 0; font-size: 12px; }
|
||||
|
||||
.match-success {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.match-success .match-banner-icon { color: #059669; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
|
||||
|
||||
.match-warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.match-warning .match-banner-icon { color: #d97706; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
|
||||
|
||||
/* Company list */
|
||||
.company-list {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.company-item:last-child { border-bottom: none; }
|
||||
|
||||
.company-item:hover { background: #f8fafc; }
|
||||
|
||||
.company-matched {
|
||||
background: #f0fdf4 !important;
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.company-match-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.company-match-icon.match { color: #22c55e; }
|
||||
.company-match-icon.no-match { color: #d1d5db; }
|
||||
|
||||
.company-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.company-name { font-size: 13px; font-weight: 600; color: #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.company-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
|
||||
/* Fail reason */
|
||||
.fail-reason {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: #fef2f2; border: 1px solid #fecaca; padding: 10px 14px;
|
||||
border-radius: 6px; font-size: 13px; color: #991b1b; line-height: 1.5;
|
||||
}
|
||||
.fail-reason-icon { font-size: 16px; color: #dc2626; flex-shrink: 0; }
|
||||
|
||||
/* Error */
|
||||
.error-icon-large { font-size: 48px; color: #f57c00; }
|
||||
.error-msg { font-size: 16px; font-weight: 600; color: #f57c00; margin: 0; text-align: center; }
|
||||
.error-detail { font-size: 14px; color: #555; text-align: center; }
|
||||
|
||||
mat-dialog-actions { padding: 8px 24px 16px; }
|
||||
mat-dialog-actions button { display: flex; align-items: center; gap: 6px; }
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatProgressBarModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DinVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
private dialogRef = inject(MatDialogRef<DinVerifyDialogComponent>);
|
||||
private data: {
|
||||
clientId: string;
|
||||
kycId: string;
|
||||
signatoryName: string;
|
||||
cifNumber: string;
|
||||
panNumber: string;
|
||||
entityName: string;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
signatoryName = '';
|
||||
cifNumber = '';
|
||||
panNumber = '';
|
||||
entityName = '';
|
||||
dinNumber = '';
|
||||
|
||||
verifying = false;
|
||||
verified = false;
|
||||
failed = false;
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
failReason = '';
|
||||
|
||||
directorName = '';
|
||||
dinStatus = '';
|
||||
dinDesignation = '';
|
||||
verifiedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
|
||||
/** Companies list from DIN verification response */
|
||||
companies: any[] = [];
|
||||
/** Whether entity name matched a company */
|
||||
companyMatch = false;
|
||||
/** The matched company name */
|
||||
matchedCompany = '';
|
||||
|
||||
progress = 0;
|
||||
currentStep = 0;
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to MCA portal...', icon: 'globe' as any },
|
||||
{ label: 'Looking up DIN records...', icon: 'search' as any },
|
||||
{ label: 'Matching entity with companies...', icon: 'building' as any },
|
||||
{ label: 'Saving verification...', icon: 'database' as any }
|
||||
];
|
||||
|
||||
private intervalId: any;
|
||||
private apiResponse: any = null;
|
||||
private apiError: any = null;
|
||||
private apiDone = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.signatoryName = this.data?.signatoryName || '';
|
||||
this.cifNumber = this.data?.cifNumber || '';
|
||||
this.panNumber = this.data?.panNumber || '-';
|
||||
this.entityName = this.data?.entityName || '';
|
||||
}
|
||||
|
||||
onVerify() {
|
||||
if (!this.dinNumber || this.dinNumber.length < 8) return;
|
||||
|
||||
this.verifying = true;
|
||||
this.verified = false;
|
||||
this.failed = false;
|
||||
this.errorOccurred = false;
|
||||
this.apiDone = false;
|
||||
this.apiResponse = null;
|
||||
this.apiError = null;
|
||||
this.progress = 0;
|
||||
this.currentStep = 0;
|
||||
this.companies = [];
|
||||
this.companyMatch = false;
|
||||
this.matchedCompany = '';
|
||||
|
||||
this.dialogRef.disableClose = true;
|
||||
|
||||
// Fire API call — pass clientId, kycId, dinNumber and entityName
|
||||
this.clientsService.verifyDIN(this.data.clientId, this.data.kycId, this.dinNumber, this.entityName).subscribe({
|
||||
next: (res: any) => {
|
||||
this.apiResponse = res;
|
||||
this.apiDone = true;
|
||||
this.tryShowResult();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.apiError = err;
|
||||
this.apiDone = true;
|
||||
this.tryShowResult();
|
||||
}
|
||||
});
|
||||
|
||||
// Animate progress
|
||||
this.intervalId = setInterval(() => {
|
||||
this.progress += 2;
|
||||
this.currentStep = Math.min(Math.floor(this.progress / 25), this.progressSteps.length - 1);
|
||||
if (this.progress >= 100) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
this.tryShowResult();
|
||||
}
|
||||
}, 60);
|
||||
}
|
||||
|
||||
private tryShowResult() {
|
||||
if (!this.apiDone || this.progress < 100) return;
|
||||
|
||||
this.verifying = false;
|
||||
this.dialogRef.disableClose = false;
|
||||
|
||||
if (this.apiError) {
|
||||
this.errorOccurred = true;
|
||||
this.errorMessage = this.apiError?.error?.defaultUserMessage || this.apiError?.error?.message || this.apiError?.message || 'Unable to verify DIN. Please try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = this.apiResponse;
|
||||
const changes = res?.changes || res;
|
||||
|
||||
if (changes?.dinVerified || res?.dinVerified) {
|
||||
this.verified = true;
|
||||
this.directorName = changes?.directorName || res?.directorName || '';
|
||||
this.dinStatus = changes?.dinStatus || res?.dinStatus || 'Active';
|
||||
this.dinDesignation = changes?.dinDesignation || res?.dinDesignation || '';
|
||||
this.verifiedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
|
||||
// Process company list from response
|
||||
const rawCompanies = changes?.companies || res?.companies || [];
|
||||
this.companies = rawCompanies.map((c: any) => ({
|
||||
...c,
|
||||
companyName: c.companyName || c.name || '',
|
||||
cin: c.cin || c.cinNumber || '',
|
||||
designation: c.designation || '',
|
||||
appointmentDate: c.appointmentDate || c.dateOfAppointment || '',
|
||||
_matched: false
|
||||
}));
|
||||
|
||||
// Match entity name against company list
|
||||
this.matchCompanyName();
|
||||
} else {
|
||||
this.failed = true;
|
||||
this.failReason = changes?.dinFailReason || res?.failReason || res?.defaultUserMessage || 'DIN could not be verified against MCA records.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Match the entity name against the list of companies from the DIN response */
|
||||
private matchCompanyName() {
|
||||
if (!this.entityName || this.companies.length === 0) return;
|
||||
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const entityNorm = normalize(this.entityName);
|
||||
|
||||
for (const company of this.companies) {
|
||||
const compNorm = normalize(company.companyName);
|
||||
// Exact match or substring containment
|
||||
if (compNorm === entityNorm || compNorm.includes(entityNorm) || entityNorm.includes(compNorm)) {
|
||||
company._matched = true;
|
||||
this.companyMatch = true;
|
||||
this.matchedCompany = company.companyName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSkip() {
|
||||
this.dialogRef.close({ skipped: true });
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.verified = false;
|
||||
this.failed = false;
|
||||
this.errorOccurred = false;
|
||||
this.progress = 0;
|
||||
this.currentStep = 0;
|
||||
this.companies = [];
|
||||
this.companyMatch = false;
|
||||
this.matchedCompany = '';
|
||||
}
|
||||
|
||||
onClose(verified: boolean) {
|
||||
this.dialogRef.close({
|
||||
verified,
|
||||
dinNumber: this.dinNumber,
|
||||
directorName: this.directorName,
|
||||
dinStatus: this.dinStatus,
|
||||
dinDesignation: this.dinDesignation,
|
||||
dinVerified: verified,
|
||||
companyMatch: this.companyMatch,
|
||||
matchedCompany: this.matchedCompany,
|
||||
companies: this.companies,
|
||||
response: this.apiResponse
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -38,7 +38,7 @@ $muted: #64748b;
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ $muted: #64748b;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.addr-grid--2, .addr-grid--3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -305,23 +305,85 @@
|
||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- GSTIN (all non-individual) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>GSTIN Number</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
placeholder="15-character GSTIN"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<!-- GSTIN (all non-individual) with Verify -->
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>GSTIN Number</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
||||
<input
|
||||
matInput
|
||||
formControlName="gstinNumber"
|
||||
placeholder="15-character GSTIN"
|
||||
maxlength="15"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'FAILED') {
|
||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="verify-btn-inline"
|
||||
[disabled]="
|
||||
readOnly ||
|
||||
!kycForm.get('gstinNumber')?.value ||
|
||||
kycForm.get('gstinNumber')?.value.length < 15 ||
|
||||
gstinVerifying ||
|
||||
!clientId
|
||||
"
|
||||
(click)="verifyGstin()"
|
||||
>
|
||||
@if (gstinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||
<div class="pan-result-card full-width">
|
||||
<div class="pan-result-header">
|
||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||
<span>GSTIN Verified Successfully</span>
|
||||
</div>
|
||||
<div class="pan-result-body">
|
||||
@if (gstinVerifyName) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Trade Name / Legal Name</span>
|
||||
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (gstinVerifyState) {
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">State</span>
|
||||
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="pan-result-row">
|
||||
<span class="pan-result-label">Verified On</span>
|
||||
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||
|
||||
@@ -263,6 +263,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
entPanVerifyName = '';
|
||||
entPanVerifiedOn = '';
|
||||
|
||||
/** GSTIN verification */
|
||||
gstinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
gstinVerifying = false;
|
||||
gstinVerifyName = '';
|
||||
gstinVerifiedOn = '';
|
||||
gstinVerifyState = '';
|
||||
|
||||
/** CIN verification (dummy) */
|
||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
cinVerifying = false;
|
||||
@@ -480,6 +487,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (data.panAadhaarSeedingStatus) {
|
||||
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
|
||||
}
|
||||
if (data.gstinVerified) {
|
||||
this.gstinVerifyStatus = 'VERIFIED';
|
||||
this.gstinVerifyName = data.gstinTradeName || data.gstinLegalName || '';
|
||||
this.gstinVerifyState = data.gstinState || '';
|
||||
const rawGstinDate = data.gstinVerifiedOn;
|
||||
if (Array.isArray(rawGstinDate) && rawGstinDate.length >= 3) {
|
||||
this.gstinVerifiedOn = `${rawGstinDate[2]?.toString().padStart(2, '0')}/${rawGstinDate[1]?.toString().padStart(2, '0')}/${rawGstinDate[0]}`;
|
||||
} else if (rawGstinDate) {
|
||||
this.gstinVerifiedOn = rawGstinDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load existing photo & signature
|
||||
this.loadExistingPhoto();
|
||||
@@ -772,6 +790,68 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify GSTIN via API */
|
||||
verifyGstin() {
|
||||
if (!this.clientId) return;
|
||||
const gstin = this.kycForm.value.gstinNumber;
|
||||
if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
|
||||
|
||||
this.gstinVerifying = true;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
gstinNumber: gstin,
|
||||
dateFormat,
|
||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: 'XXXXXXXX0000'
|
||||
};
|
||||
|
||||
this.saveKycAndDo(
|
||||
payload,
|
||||
() => {
|
||||
this.clientsService.verifyGSTIN(this.clientId, this.kycId).subscribe({
|
||||
next: (res: any) => {
|
||||
this.gstinVerifying = false;
|
||||
const changes = res?.changes || res;
|
||||
if (changes?.gstinVerified === true) {
|
||||
this.gstinVerifyStatus = 'VERIFIED';
|
||||
this.gstinVerifyName = changes?.gstinTradeName || changes?.gstinLegalName || '';
|
||||
this.gstinVerifyState = changes?.gstinState || '';
|
||||
const rawDate = changes?.gstinVerifiedOn;
|
||||
if (Array.isArray(rawDate)) {
|
||||
this.gstinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.gstinVerifiedOn =
|
||||
rawDate ||
|
||||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||
}
|
||||
} else {
|
||||
this.gstinVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(
|
||||
changes?.gstinVerificationMessage || 'GSTIN verification failed. Please check the GSTIN number and try again.'
|
||||
);
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.gstinVerifying = false;
|
||||
this.gstinVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'GSTIN verification failed.'
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
(msg) => {
|
||||
this.gstinVerifying = false;
|
||||
this.showPanErrorAlert(msg);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify CIN (dummy — no real API) */
|
||||
verifyCin() {
|
||||
const cin = this.kycForm.value.cinNumber;
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ $navy: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.crop-workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
+21
-21
@@ -32,7 +32,7 @@
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
@@ -96,9 +96,9 @@
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: rgb(255 255 255 / 70%);
|
||||
color: #555;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
.chip-icon {
|
||||
font-size: 10px;
|
||||
@@ -120,7 +120,7 @@
|
||||
|
||||
/* ── Info Cards ── */
|
||||
.info-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgb(0 0 0 / 8%);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
@@ -132,7 +132,7 @@
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
@@ -177,8 +177,8 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 4%);
|
||||
border-right: 1px solid rgb(0 0 0 / 4%);
|
||||
|
||||
&:nth-child(3n) {
|
||||
border-right: none;
|
||||
@@ -195,7 +195,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
@@ -204,7 +204,7 @@
|
||||
color: #1a1a1a;
|
||||
|
||||
&.no-data {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: rgb(0 0 0 / 30%);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -239,7 +239,7 @@
|
||||
border-collapse: collapse;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 4%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -254,7 +254,7 @@
|
||||
.info-label {
|
||||
width: 40%;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-weight: 500;
|
||||
padding-right: 16px;
|
||||
}
|
||||
@@ -278,17 +278,17 @@
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
color: rgb(0 0 0 / 55%);
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 4%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -349,7 +349,7 @@
|
||||
.address-block {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px dashed rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -381,7 +381,7 @@
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px 0 4px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -394,23 +394,23 @@
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: rgb(0 0 0 / 60%);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 960px) {
|
||||
@media (width <= 960px) {
|
||||
.preview-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
&:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); }
|
||||
&:nth-child(3n) { border-right: 1px solid rgb(0 0 0 / 4%); }
|
||||
&:nth-child(2n) { border-right: none; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.profile-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
@@ -92,7 +92,7 @@ $red-light: #fce8e6;
|
||||
margin-bottom: 12px !important;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid $gray-200 !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 6%) !important;
|
||||
|
||||
&::ng-deep .mat-expansion-panel-body {
|
||||
padding: 0 20px 20px !important;
|
||||
@@ -194,11 +194,11 @@ $red-light: #fce8e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (width <= 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ $red-light: #fce8e6;
|
||||
word-break: break-word;
|
||||
|
||||
&--mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -24,52 +24,108 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Section: Read-Only Client Info -->
|
||||
<mat-card class="ckyc-info-card">
|
||||
<mat-card-content>
|
||||
<div class="ckyc-info-grid">
|
||||
<!-- Account Number Dropdown -->
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">{{ 'labels.inputs.Account Number' | translate }}</span>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-select
|
||||
[value]="selectedAccount?.accountNo || ''"
|
||||
(selectionChange)="onAccountSelect($event.value)"
|
||||
[placeholder]="'labels.inputs.Select Account' | translate"
|
||||
>
|
||||
@for (account of allAccounts; track account.accountNo) {
|
||||
<mat-option [value]="account.accountNo">
|
||||
{{ account.accountNo }} ({{ account.productName }})
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Client ID -->
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">{{ 'labels.inputs.Client ID' | translate }}</span>
|
||||
<span class="ckyc-info-value">{{ clientId }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Client Name -->
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">{{ 'labels.inputs.Client Name' | translate }}</span>
|
||||
<span class="ckyc-info-value">{{ clientName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Account Type -->
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">{{ 'labels.inputs.Account Type' | translate }}</span>
|
||||
<span class="ckyc-info-value">{{ selectedAccountType || '—' }}</span>
|
||||
</div>
|
||||
<!-- CERSAI Sync Status hero -->
|
||||
<div class="cersai-hero" [class.cersai-hero--synced]="ckycNumber" [class.cersai-hero--pending]="!ckycNumber">
|
||||
<div class="cersai-hero__left">
|
||||
<div class="cersai-hero__icon">
|
||||
@if (ckycNumber) {
|
||||
<fa-icon icon="check-double"></fa-icon>
|
||||
} @else {
|
||||
<fa-icon icon="hourglass-half"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="cersai-hero__text">
|
||||
<div class="cersai-hero__status">
|
||||
@if (ckycNumber) {
|
||||
<span class="cersai-hero__tag">SYNCED WITH CERSAI</span>
|
||||
} @else {
|
||||
<span class="cersai-hero__tag cersai-hero__tag--pending">PENDING CERSAI SYNC</span>
|
||||
}
|
||||
</div>
|
||||
@if (ckycNumber) {
|
||||
<div class="cersai-hero__ckyc-label">CKYC Registry Number</div>
|
||||
<div class="cersai-hero__ckyc-number">{{ ckycNumber }}</div>
|
||||
<div class="cersai-hero__meta">
|
||||
@if (ckycUploadedOn) {
|
||||
<span>
|
||||
<fa-icon icon="calendar-check"></fa-icon>
|
||||
Uploaded on <strong>{{ ckycUploadedOn }}</strong>
|
||||
</span>
|
||||
}
|
||||
@if (ckycBatchId) {
|
||||
<span class="cersai-hero__sep">·</span>
|
||||
<span>
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
Batch <strong>{{ ckycBatchId }}</strong>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cersai-hero__ckyc-label">This client has not yet been synced to the CERSAI registry</div>
|
||||
<div class="cersai-hero__hint">
|
||||
Collect all 4 artefacts below, then create a batch from the Record Master to upload.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ckycNumber) {
|
||||
<div class="cersai-hero__pulse">
|
||||
<span class="cersai-hero__pulse-dot"></span>
|
||||
Live
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Client basic info -->
|
||||
<div class="ckyc-info-grid">
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">
|
||||
<fa-icon icon="hashtag"></fa-icon> CIF Number
|
||||
</span>
|
||||
<span class="ckyc-info-value ckyc-info-value--mono">{{ cifNumber || clientId }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">
|
||||
<fa-icon icon="user"></fa-icon> Full Name
|
||||
</span>
|
||||
<span class="ckyc-info-value">{{ clientName || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">
|
||||
<fa-icon icon="envelope"></fa-icon> Email
|
||||
</span>
|
||||
@if (clientEmail) {
|
||||
<span class="ckyc-info-value">{{ clientEmail }}</span>
|
||||
} @else {
|
||||
<span class="ckyc-info-value ckyc-info-value--missing">No email on file</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ckyc-info-item">
|
||||
<span class="ckyc-info-label">
|
||||
<fa-icon icon="phone"></fa-icon> Mobile
|
||||
</span>
|
||||
@if (clientMobile) {
|
||||
<span class="ckyc-info-value">{{ clientMobile }}</span>
|
||||
} @else {
|
||||
<span class="ckyc-info-value ckyc-info-value--missing">No mobile on file</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Upload Section -->
|
||||
<h4 class="ckyc-section-title">{{ 'labels.heading.CKYC Documents' | translate }}</h4>
|
||||
|
||||
@if (checkingExisting) {
|
||||
<div class="ckyc-existing-loading">
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Checking for already-uploaded artefacts…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ckyc-upload-grid">
|
||||
<!-- Address Proof Document -->
|
||||
<mat-card class="ckyc-upload-card">
|
||||
@@ -78,6 +134,41 @@
|
||||
<mat-icon class="ckyc-upload-icon">home</mat-icon>
|
||||
<span>{{ 'labels.inputs.Address Proof Document' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (existing.ADDRESS_PROOF.present) {
|
||||
<div class="ckyc-existing">
|
||||
<div class="ckyc-existing__head">
|
||||
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="file-alt"></fa-icon>
|
||||
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
||||
@if (existing.ADDRESS_PROOF.documentKey) {
|
||||
<span class="ckyc-existing__key">{{ existing.ADDRESS_PROOF.documentKey }}</span>
|
||||
}
|
||||
@if (existing.ADDRESS_PROOF.fileCount && existing.ADDRESS_PROOF.fileCount > 0) {
|
||||
<span class="ckyc-existing__count">
|
||||
{{ existing.ADDRESS_PROOF.fileCount }} {{ existing.ADDRESS_PROOF.fileCount === 1 ? 'file' : 'files' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__replace"
|
||||
(click)="toggleReplace('ADDRESS_PROOF')"
|
||||
>
|
||||
<fa-icon [icon]="existing.ADDRESS_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
|
||||
{{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
|
||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
|
||||
<div class="ckyc-file-row">
|
||||
<input
|
||||
@@ -137,6 +228,7 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -147,6 +239,41 @@
|
||||
<mat-icon class="ckyc-upload-icon">photo_camera</mat-icon>
|
||||
<span>{{ 'labels.inputs.Photo Proof Document' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (existing.PHOTO_PROOF.present) {
|
||||
<div class="ckyc-existing">
|
||||
<div class="ckyc-existing__head">
|
||||
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="id-card"></fa-icon>
|
||||
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
||||
@if (existing.PHOTO_PROOF.documentKey) {
|
||||
<span class="ckyc-existing__key">{{ existing.PHOTO_PROOF.documentKey }}</span>
|
||||
}
|
||||
@if (existing.PHOTO_PROOF.fileCount && existing.PHOTO_PROOF.fileCount > 0) {
|
||||
<span class="ckyc-existing__count">
|
||||
{{ existing.PHOTO_PROOF.fileCount }} {{ existing.PHOTO_PROOF.fileCount === 1 ? 'file' : 'files' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__replace"
|
||||
(click)="toggleReplace('PHOTO_PROOF')"
|
||||
>
|
||||
<fa-icon [icon]="existing.PHOTO_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
|
||||
{{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
|
||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
|
||||
<div class="ckyc-file-row">
|
||||
<input
|
||||
@@ -206,6 +333,7 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -216,6 +344,32 @@
|
||||
<mat-icon class="ckyc-upload-icon">person</mat-icon>
|
||||
<span>{{ 'labels.inputs.Image of Client' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (existing.CLIENT_IMAGE.present) {
|
||||
<div class="ckyc-existing">
|
||||
<div class="ckyc-existing__head">
|
||||
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.CLIENT_IMAGE.previewUrl) {
|
||||
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__replace"
|
||||
(click)="toggleReplace('CLIENT_IMAGE')"
|
||||
>
|
||||
<fa-icon [icon]="existing.CLIENT_IMAGE.expanded ? 'times' : 'upload'"></fa-icon>
|
||||
{{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
|
||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
|
||||
<div class="ckyc-file-row">
|
||||
<input
|
||||
@@ -275,6 +429,7 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -285,6 +440,36 @@
|
||||
<mat-icon class="ckyc-upload-icon">draw</mat-icon>
|
||||
<span>{{ 'labels.inputs.Signature of Client' | translate }}</span>
|
||||
</div>
|
||||
|
||||
@if (existing.CLIENT_SIGNATURE.present) {
|
||||
<div class="ckyc-existing">
|
||||
<div class="ckyc-existing__head">
|
||||
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
|
||||
<div>
|
||||
<strong>Already available</strong>
|
||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (existing.CLIENT_SIGNATURE.previewUrl) {
|
||||
<img [src]="existing.CLIENT_SIGNATURE.previewUrl" alt="Client signature" class="ckyc-existing__thumb ckyc-existing__thumb--wide" />
|
||||
}
|
||||
<div class="ckyc-existing__meta">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="ckyc-existing__replace"
|
||||
(click)="toggleReplace('CLIENT_SIGNATURE')"
|
||||
>
|
||||
<fa-icon [icon]="existing.CLIENT_SIGNATURE.expanded ? 'times' : 'upload'"></fa-icon>
|
||||
{{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
|
||||
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
|
||||
<div class="ckyc-file-row">
|
||||
<input
|
||||
@@ -344,6 +529,7 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@@ -49,37 +49,236 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ckyc-info-card {
|
||||
margin-bottom: 24px;
|
||||
/* ===================== CERSAI Sync Hero ===================== */
|
||||
.cersai-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 26px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 18px;
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
right: -10%;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--synced {
|
||||
background: linear-gradient(135deg, #0f6e56 0%, #1d9e75 55%, #2fb88c 100%);
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: linear-gradient(135deg, #8a5a14 0%, #ba7517 55%, #d4892f 100%);
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
flex-shrink: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
&__text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
&__ckyc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
&__ckyc-number {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.95;
|
||||
flex-wrap: wrap;
|
||||
|
||||
fa-icon { font-size: 11px; opacity: 0.9; margin-right: 3px; }
|
||||
strong { font-weight: 700; }
|
||||
}
|
||||
|
||||
&__sep { opacity: 0.5; }
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
max-width: 480px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__pulse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
backdrop-filter: blur(8px);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #9fe1cb;
|
||||
box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7);
|
||||
animation: cersai-pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cersai-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(159, 225, 203, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0); }
|
||||
}
|
||||
|
||||
/* ===================== Client info grid ===================== */
|
||||
.ckyc-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.ckyc-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eaed;
|
||||
border-radius: 12px;
|
||||
min-width: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #c6dafc;
|
||||
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.ckyc-info-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cersai-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
&__left { width: 100%; }
|
||||
&__icon { width: 52px; height: 52px; font-size: 22px; }
|
||||
&__ckyc-number { font-size: 20px; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 500px) {
|
||||
.ckyc-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ckyc-info-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #5f6368;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.06em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
fa-icon { color: #1a73e8; font-size: 11px; }
|
||||
}
|
||||
|
||||
.ckyc-info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-height: 20px;
|
||||
font-size: 13px;
|
||||
color: #202124;
|
||||
font-weight: 600;
|
||||
min-height: 18px;
|
||||
word-break: break-word;
|
||||
|
||||
&--mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #1a73e8;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&--missing {
|
||||
color: #9aa0a6;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.ckyc-info-hint { display: none; }
|
||||
|
||||
.ckyc-section-title {
|
||||
margin: 24px 0 12px;
|
||||
font-weight: 600;
|
||||
@@ -87,31 +286,58 @@
|
||||
|
||||
.ckyc-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ckyc-upload-card {
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid #e8eaed;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
|
||||
&:hover {
|
||||
border-color: #c6dafc;
|
||||
box-shadow: 0 2px 10px rgba(26, 115, 232, 0.06) !important;
|
||||
}
|
||||
|
||||
.mat-mdc-card-content {
|
||||
padding: 16px;
|
||||
padding: 18px 20px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ckyc-upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
color: #202124;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
}
|
||||
|
||||
.ckyc-upload-icon {
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
color: #1976d2;
|
||||
font-size: 18px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1a73e8, #4285f4);
|
||||
border-radius: 8px;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ckyc-upload-hint {
|
||||
@@ -163,6 +389,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.ckyc-upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.ckyc-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -180,3 +412,149 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== Already-available banners ===================== */
|
||||
|
||||
.ckyc-existing-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: #e8f0fe;
|
||||
color: #0c447c;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
mat-progress-spinner { display: inline-block; }
|
||||
}
|
||||
|
||||
.ckyc-existing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 12px;
|
||||
background: #f0fbf6;
|
||||
border: 1px solid #cfecdd;
|
||||
border-radius: 10px;
|
||||
margin-top: 14px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: #1d9e75;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #085041;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
&__ok {
|
||||
font-size: 20px;
|
||||
color: #1d9e75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__source {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #0f6e56;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #085041;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #cfecdd;
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-of-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
fa-icon { color: #1d9e75; font-size: 12px; }
|
||||
}
|
||||
|
||||
&__key {
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
background: #e1f5ee;
|
||||
color: #0f6e56;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cfecdd;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #1d9e75;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #fff;
|
||||
outline: 1px solid #cfecdd;
|
||||
background: #fff;
|
||||
object-fit: cover;
|
||||
align-self: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&--wide {
|
||||
width: 200px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__replace {
|
||||
align-self: stretch;
|
||||
margin-top: auto;
|
||||
height: 34px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #cfecdd !important;
|
||||
color: #0f6e56 !important;
|
||||
background: #fff !important;
|
||||
padding: 0 12px !important;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
fa-icon { margin-right: 6px; font-size: 11px; }
|
||||
|
||||
&:hover {
|
||||
background: #e1f5ee !important;
|
||||
border-color: #1d9e75 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Subject, takeUntil, catchError, of } from 'rxjs';
|
||||
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
@@ -19,6 +19,17 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
interface ExistingArtefact {
|
||||
present: boolean;
|
||||
name?: string;
|
||||
source?: string;
|
||||
previewUrl?: string;
|
||||
documentId?: string;
|
||||
documentKey?: string;
|
||||
fileCount?: number;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-ckyc-tab',
|
||||
templateUrl: './ckyc-tab.component.html',
|
||||
@@ -41,6 +52,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
/** Top section - read-only client info */
|
||||
clientId: string;
|
||||
clientName: string = '';
|
||||
clientEmail: string = '';
|
||||
clientMobile: string = '';
|
||||
cifNumber: string = '';
|
||||
ckycNumber: string = '';
|
||||
allAccounts: any[] = [];
|
||||
selectedAccount: any = null;
|
||||
selectedAccountType: string = '';
|
||||
@@ -48,6 +63,17 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
/** State */
|
||||
loading = true;
|
||||
ckycData: any = null;
|
||||
ckycUploadedOn = '';
|
||||
ckycBatchId = '';
|
||||
checkingExisting = false;
|
||||
|
||||
/** Pre-existing artefacts detected from client data */
|
||||
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
|
||||
ADDRESS_PROOF: { present: false },
|
||||
PHOTO_PROOF: { present: false },
|
||||
CLIENT_IMAGE: { present: false },
|
||||
CLIENT_SIGNATURE: { present: false }
|
||||
};
|
||||
|
||||
/** File references */
|
||||
addressProofFile: File | null = null;
|
||||
@@ -80,13 +106,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
this.loadClientInfo();
|
||||
this.loadAccounts();
|
||||
this.loadCkycData();
|
||||
this.loadExistingArtefacts();
|
||||
});
|
||||
}
|
||||
|
||||
private loadClientInfo(): void {
|
||||
this.route.parent.data.subscribe((data: any) => {
|
||||
if (data?.clientViewData) {
|
||||
this.clientName = data.clientViewData.displayName || '';
|
||||
const cv = data?.clientViewData;
|
||||
if (cv) {
|
||||
this.clientName = cv.displayName || '';
|
||||
this.clientEmail = cv.emailAddress || '';
|
||||
this.clientMobile = cv.mobileNo || '';
|
||||
this.cifNumber = cv.accountNo || cv.externalId || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -122,8 +153,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadCkycData(): void {
|
||||
this.loading = true;
|
||||
// Read CKYC registry data from /clients/{id}/kyc — that's where the
|
||||
// SFTP upload flow persists ckycNumber / ckycUploadedOn / ckycBatchId.
|
||||
this.clientsService
|
||||
.getClientCkyc(this.clientId)
|
||||
.getClientKyc(this.clientId)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(() => {
|
||||
@@ -134,9 +167,120 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
.subscribe((data: any) => {
|
||||
this.loading = false;
|
||||
this.ckycData = data;
|
||||
this.ckycNumber = data?.ckycNumber || '';
|
||||
this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
|
||||
this.ckycBatchId = data?.ckycBatchId || '';
|
||||
});
|
||||
}
|
||||
|
||||
private formatJavaDate(value: any): string {
|
||||
if (!value) return '';
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d] = value;
|
||||
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo (profile image), signature (client document),
|
||||
* ID proof and address proof (client identifiers) so already-uploaded
|
||||
* artefacts are shown here instead of forcing re-upload.
|
||||
*/
|
||||
private loadExistingArtefacts(): void {
|
||||
this.checkingExisting = true;
|
||||
|
||||
forkJoin({
|
||||
photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
|
||||
documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
|
||||
identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
|
||||
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(({ photo, documents, identifiers, proofTypes }) => {
|
||||
this.checkingExisting = false;
|
||||
|
||||
// 1. Photo / image of client — from m_client.image_id
|
||||
if (photo && typeof photo === 'string' && photo.length > 0) {
|
||||
this.existing.CLIENT_IMAGE = {
|
||||
present: true,
|
||||
source: 'Client registration',
|
||||
previewUrl: photo,
|
||||
name: 'Profile photo'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document with name='clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
if (signatureDoc) {
|
||||
this.existing.CLIENT_SIGNATURE = {
|
||||
present: true,
|
||||
source: 'Client registration',
|
||||
name: signatureDoc.fileName || 'Client signature',
|
||||
documentId: signatureDoc.id
|
||||
};
|
||||
// Fetch blob for preview
|
||||
this.clientsService
|
||||
.getClientSignatureImage(this.clientId, signatureDoc.id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob) {
|
||||
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
|
||||
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
|
||||
const addressProofIds = new Set<string>(
|
||||
(proofTypes?.addressProofs || []).map((p: any) => String(p.id))
|
||||
);
|
||||
|
||||
const idList = Array.isArray(identifiers)
|
||||
? identifiers
|
||||
: (identifiers as any)?.pageItems || [];
|
||||
|
||||
for (const identity of idList) {
|
||||
const docTypeId = String(identity?.documentType?.id ?? '');
|
||||
const isActive =
|
||||
!identity?.status ||
|
||||
identity?.status?.code === 'clientIdentifierStatusType.active' ||
|
||||
identity?.status === 'clientIdentifierStatusType.active' ||
|
||||
identity?.status === 'Active';
|
||||
if (!isActive) continue;
|
||||
|
||||
const proofName = identity?.documentType?.name || 'Identifier';
|
||||
// Address proof documentKey is auto-generated as "<ProofName> - <clientId>"
|
||||
// in identities-tab; hide it when it's just that synthetic key.
|
||||
const rawKey = identity?.documentKey || '';
|
||||
const isSyntheticKey = rawKey.startsWith(`${proofName} - `);
|
||||
const common = {
|
||||
present: true,
|
||||
source: 'Identities section',
|
||||
name: proofName,
|
||||
documentKey: isSyntheticKey ? '' : rawKey,
|
||||
documentId: identity?.id,
|
||||
fileCount: (identity?.documents || []).length
|
||||
};
|
||||
|
||||
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
|
||||
this.existing.PHOTO_PROOF = common;
|
||||
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
|
||||
this.existing.ADDRESS_PROOF = common;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleReplace(documentType: string): void {
|
||||
const entry = (this.existing as any)[documentType];
|
||||
if (entry) entry.expanded = !entry.expanded;
|
||||
}
|
||||
|
||||
onAccountSelect(accountNo: string): void {
|
||||
this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null;
|
||||
this.selectedAccountType = this.selectedAccount?.accountType || '';
|
||||
@@ -215,5 +359,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
// Release object URLs
|
||||
Object.values(this.existing).forEach((e) => {
|
||||
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ $accent: #0ea5e9;
|
||||
background: #fff !important;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgb(0 0 0 / 6%);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 4%);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -42,10 +42,7 @@ $accent: #0ea5e9;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -69,7 +66,7 @@ $accent: #0ea5e9;
|
||||
height: 72px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border: 2px solid rgb(255 255 255 / 25%);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -84,9 +81,9 @@ $accent: #0ea5e9;
|
||||
min-width: 26px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 5px !important;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
background: rgb(255 255 255 / 10%) !important;
|
||||
color: rgb(255 255 255 / 60%) !important;
|
||||
border: 1px solid rgb(255 255 255 / 15%) !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -96,19 +93,19 @@ $accent: #0ea5e9;
|
||||
i { font-size: 10px; }
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
background: rgb(255 255 255 / 20%) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.35) !important;
|
||||
border-color: rgba(239, 68, 68, 0.45) !important;
|
||||
background: rgb(239 68 68 / 35%) !important;
|
||||
border-color: rgb(239 68 68 / 45%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__sig {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: rgb(255 255 255 / 60%);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -148,23 +145,23 @@ $accent: #0ea5e9;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
background: rgb(16 185 129 / 20%);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
border: 1px solid rgb(16 185 129 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.cv-menu-btn {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
color: rgb(255 255 255 / 70%) !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 28px !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgb(255 255 255 / 15%);
|
||||
border-radius: 5px !important;
|
||||
line-height: 28px !important;
|
||||
|
||||
&:hover { background: rgba(255, 255, 255, 0.1); color: #fff !important; }
|
||||
&:hover { background: rgb(255 255 255 / 10%); color: #fff !important; }
|
||||
|
||||
.actions-menu {
|
||||
font-size: 14px !important;
|
||||
@@ -187,14 +184,14 @@ $accent: #0ea5e9;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 11px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 5px;
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: rgb(255 255 255 / 40%);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
@@ -203,7 +200,7 @@ $accent: #0ea5e9;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
@@ -220,19 +217,19 @@ $accent: #0ea5e9;
|
||||
|
||||
.cv-cell {
|
||||
padding: 9px 13px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: rgb(255 255 255 / 7%);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
transition: background 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: rgb(255 255 255 / 45%);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
@@ -253,9 +250,9 @@ $accent: #0ea5e9;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--green { background: rgba(16, 185, 129, 0.2); color: #6ee7b7; }
|
||||
&--yellow { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
&--red { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||
&--green { background: rgb(16 185 129 / 20%); color: #6ee7b7; }
|
||||
&--yellow { background: rgb(245 158 11 / 20%); color: #fcd34d; }
|
||||
&--red { background: rgb(239 68 68 / 20%); color: #fca5a5; }
|
||||
}
|
||||
|
||||
.actions-menu { color: #fff !important; }
|
||||
@@ -263,7 +260,7 @@ $accent: #0ea5e9;
|
||||
// ── Tab bar ──
|
||||
.cv-tabs {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 6%);
|
||||
|
||||
::ng-deep .mat-mdc-tab-link {
|
||||
font-size: 12px !important;
|
||||
@@ -292,7 +289,7 @@ $accent: #0ea5e9;
|
||||
button,
|
||||
mat-icon,
|
||||
fa-icon {
|
||||
color: rgba(255, 255, 255, 0.4) !important;
|
||||
color: rgb(255 255 255 / 40%) !important;
|
||||
font-size: 11px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
@@ -311,8 +308,8 @@ $accent: #0ea5e9;
|
||||
// ── Client menu styling ──
|
||||
::ng-deep .mat-mdc-menu-panel {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important;
|
||||
border: 1px solid rgb(0 0 0 / 6%) !important;
|
||||
box-shadow: 0 8px 30px rgb(0 0 0 / 12%) !important;
|
||||
min-width: 200px !important;
|
||||
padding: 6px 0 !important;
|
||||
|
||||
@@ -352,17 +349,17 @@ $accent: #0ea5e9;
|
||||
}
|
||||
|
||||
// ── Responsive ──
|
||||
@media (max-width: 1024px) {
|
||||
@media (width <= 1024px) {
|
||||
.cv-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.cv-top { flex-direction: column; align-items: center; text-align: center; }
|
||||
.cv-name-row { justify-content: center; }
|
||||
.cv-ids { justify-content: center; }
|
||||
.cv-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (width <= 480px) {
|
||||
.cv-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="icd-title">{{ title }}</h2>
|
||||
<mat-dialog-content class="icd-body">
|
||||
<p class="icd-hint">Drag the corners to crop. The result will be saved as a PNG.</p>
|
||||
<div class="icd-cropper">
|
||||
<image-cropper
|
||||
[imageFile]="data.file"
|
||||
[maintainAspectRatio]="false"
|
||||
[containWithinAspectRatio]="false"
|
||||
format="png"
|
||||
output="blob"
|
||||
(imageCropped)="imageCropped($event)"
|
||||
(imageLoaded)="imageLoaded($event)"
|
||||
(loadImageFailed)="loadImageFailed()"
|
||||
></image-cropper>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions class="icd-actions">
|
||||
<button mat-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" (click)="save()" [disabled]="!croppedBlob">Use cropped image</button>
|
||||
</mat-dialog-actions>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 480px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.icd-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding: 16px 20px 8px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.icd-body {
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.icd-hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.icd-cropper {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep image-cropper {
|
||||
display: block;
|
||||
max-height: 56vh;
|
||||
}
|
||||
}
|
||||
|
||||
.icd-actions {
|
||||
padding: 8px 20px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ImageCropperComponent, ImageCroppedEvent, LoadedImage } from 'ngx-image-cropper';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
||||
interface CropperDialogData {
|
||||
file: File;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-image-cropper-dialog',
|
||||
templateUrl: './image-cropper-dialog.component.html',
|
||||
styleUrls: ['./image-cropper-dialog.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ImageCropperComponent,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatButton
|
||||
]
|
||||
})
|
||||
export class ImageCropperDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ImageCropperDialogComponent>>(MatDialogRef);
|
||||
data = inject<CropperDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
croppedBlob: Blob | null = null;
|
||||
loading = true;
|
||||
title = '';
|
||||
|
||||
constructor() {
|
||||
this.title = this.data?.title || 'Crop image';
|
||||
}
|
||||
|
||||
imageCropped(event: ImageCroppedEvent): void {
|
||||
this.croppedBlob = event.blob || null;
|
||||
}
|
||||
|
||||
imageLoaded(_image: LoadedImage): void {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
loadImageFailed(): void {
|
||||
this.loading = false;
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.croppedBlob) {
|
||||
this.dialogRef.close(null);
|
||||
return;
|
||||
}
|
||||
const ext = this.guessExt(this.data.file.name);
|
||||
const cropped = new File([this.croppedBlob], `${this.stripExt(this.data.file.name)}-cropped.${ext}`, {
|
||||
type: this.croppedBlob.type || 'image/png'
|
||||
});
|
||||
this.dialogRef.close(cropped);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
|
||||
private stripExt(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i > 0 ? name.slice(0, i) : name;
|
||||
}
|
||||
|
||||
private guessExt(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
if (i < 0) return 'png';
|
||||
const ext = name.slice(i + 1).toLowerCase();
|
||||
return ext === 'jpg' || ext === 'jpeg' ? 'png' : ext;
|
||||
}
|
||||
}
|
||||
+434
-79
@@ -6,7 +6,7 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="fd-dialog">
|
||||
<div class="fd-dialog" [class.fd-dialog--kyc]="documentIdentifier">
|
||||
<div class="fd-header">
|
||||
<h1 mat-dialog-title class="fd-title">
|
||||
@if (documentIdentifier) {
|
||||
@@ -23,85 +23,439 @@
|
||||
</div>
|
||||
|
||||
<div class="fd-content">
|
||||
<form [formGroup]="uploadDocumentForm" class="fd-form">
|
||||
@if (documentIdentifier) {
|
||||
<!-- Identifier fields in 2-column grid -->
|
||||
<div class="fd-grid fd-grid--cols-2">
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Document Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="documentTypeId" required>
|
||||
@for (docType of allowedDocumentTypes; track docType) {
|
||||
<mat-option [value]="docType.id">
|
||||
{{ docType.name }}
|
||||
</mat-option>
|
||||
@if (documentIdentifier) {
|
||||
<div class="kyc-intro">
|
||||
<i class="fa fa-shield kyc-intro__icon"></i>
|
||||
<div>
|
||||
<strong>KYC Document Upload</strong>
|
||||
<p>
|
||||
Upload KYC documents as per RBI guidelines. Accepted:
|
||||
<i class="fa fa-file-image-o"></i> JPG,
|
||||
<i class="fa fa-file-image-o"></i> PNG,
|
||||
<i class="fa fa-file-pdf-o"></i> PDF — max 5MB each.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loadingProofs) {
|
||||
<div class="kyc-loading">
|
||||
<i class="fa fa-spinner fa-spin"></i> Loading document types…
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="uploadDocumentForm" class="kyc-form">
|
||||
<!-- SECTION 1: IDENTITY PROOF -->
|
||||
<div class="kyc-section" formGroupName="id">
|
||||
<div class="kyc-section__header">
|
||||
<div class="kyc-badge kyc-badge--blue">
|
||||
<i class="fa fa-user-circle-o"></i>
|
||||
</div>
|
||||
<div class="kyc-section__titles">
|
||||
<p class="kyc-section__title">
|
||||
<i class="fa fa-id-badge"></i> Identity proof
|
||||
</p>
|
||||
<p class="kyc-section__sub">
|
||||
Select document type — upload requirements will update automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prefilled name (from client registration) -->
|
||||
@if (clientName) {
|
||||
<div class="kyc-prefill">
|
||||
<i class="fa fa-user"></i>
|
||||
<div>
|
||||
<span class="kyc-prefill__label">Full name (from registration)</span>
|
||||
<span class="kyc-prefill__value">{{ clientName }}</span>
|
||||
</div>
|
||||
<i class="fa fa-lock kyc-prefill__lock" title="Prefilled from client registration"></i>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="kyc-field kyc-mb-sm">
|
||||
<label class="kyc-label">
|
||||
<i class="fa fa-file-text-o"></i> Document type
|
||||
</label>
|
||||
<div class="kyc-select-wrap">
|
||||
<i class="fa fa-id-card kyc-select-wrap__icon"></i>
|
||||
<select
|
||||
class="kyc-input kyc-select"
|
||||
formControlName="proofId"
|
||||
(change)="onIdProofChange($any($event.target).value)"
|
||||
>
|
||||
<option value="">— Select document —</option>
|
||||
@for (p of idProofs; track p.id) {
|
||||
<option [value]="p.id">{{ p.name }}</option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (uploadDocumentForm.controls.documentTypeId?.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Document Type' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</select>
|
||||
<i class="fa fa-chevron-down kyc-select-wrap__chevron"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedIdProof) {
|
||||
<div class="kyc-field kyc-mb-sm">
|
||||
<label class="kyc-label">
|
||||
<i class="fa fa-hashtag"></i> {{ selectedIdProof.documentKeyLabel }}
|
||||
</label>
|
||||
<div class="kyc-input-wrap">
|
||||
<i class="fa fa-pencil kyc-input-wrap__icon"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="kyc-input kyc-input--padded"
|
||||
formControlName="documentKey"
|
||||
[placeholder]="selectedIdProof.documentKeyPlaceholder || 'Enter document number'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedIdProof?.info) {
|
||||
<div class="kyc-banner kyc-banner--blue">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<span>{{ selectedIdProof.info }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedIdProof) {
|
||||
<div class="kyc-upload-grid" [class.kyc-upload-grid--two]="selectedIdProof.sides === 2">
|
||||
<div class="kyc-field">
|
||||
<div class="kyc-upload-label">
|
||||
<span class="kyc-label">
|
||||
<i class="fa fa-file-image-o"></i>
|
||||
{{ selectedIdProof.sides === 2 ? 'Front side' : (selectedIdProof.name + ' (single copy)') }}
|
||||
</span>
|
||||
<span class="kyc-tag kyc-tag--req">
|
||||
<i class="fa fa-asterisk"></i> Required
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-dropzone"
|
||||
[class.kyc-dropzone--has]="idFiles.front"
|
||||
[class.kyc-dropzone--req]="!idFiles.front"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
hidden
|
||||
(change)="onFilePick('id', 'front', $event)"
|
||||
/>
|
||||
@if (idFiles.front) {
|
||||
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">File selected</p>
|
||||
<p class="kyc-dropzone__name">
|
||||
<i class="fa fa-paperclip"></i> {{ getFileName('id', 'front') }}
|
||||
</p>
|
||||
} @else {
|
||||
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">Click to upload</p>
|
||||
<p class="kyc-dropzone__hint">
|
||||
<i class="fa fa-file-image-o"></i> JPG ·
|
||||
<i class="fa fa-file-image-o"></i> PNG ·
|
||||
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (selectedIdProof.sides === 2) {
|
||||
<div class="kyc-field">
|
||||
<div class="kyc-upload-label">
|
||||
<span class="kyc-label">
|
||||
<i class="fa fa-file-image-o"></i> Back side
|
||||
</span>
|
||||
<span class="kyc-tag kyc-tag--req">
|
||||
<i class="fa fa-asterisk"></i> Required
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-dropzone"
|
||||
[class.kyc-dropzone--has]="idFiles.back"
|
||||
[class.kyc-dropzone--req]="!idFiles.back"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
hidden
|
||||
(change)="onFilePick('id', 'back', $event)"
|
||||
/>
|
||||
@if (idFiles.back) {
|
||||
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">File selected</p>
|
||||
<p class="kyc-dropzone__name">
|
||||
<i class="fa fa-paperclip"></i> {{ getFileName('id', 'back') }}
|
||||
</p>
|
||||
} @else {
|
||||
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">Click to upload</p>
|
||||
<p class="kyc-dropzone__hint">
|
||||
<i class="fa fa-file-image-o"></i> JPG ·
|
||||
<i class="fa fa-file-image-o"></i> PNG ·
|
||||
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Status' | translate }}</mat-label>
|
||||
<mat-select formControlName="status" required>
|
||||
@for (status of statusOptions; track status) {
|
||||
<mat-option [value]="status.value">
|
||||
{{ status.label }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Document Key' | translate }}</mat-label>
|
||||
<input formControlName="documentKey" required matInput />
|
||||
@if (uploadDocumentForm.controls.documentKey?.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Document Key' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
|
||||
<input formControlName="description" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (idMergedPreview && selectedIdProof?.sides === 2) {
|
||||
<div class="kyc-merged">
|
||||
<div class="kyc-merged__head">
|
||||
<span class="kyc-merged__title">
|
||||
<i class="fa fa-object-group"></i> Combined preview (front + back)
|
||||
</span>
|
||||
<span class="kyc-merged__hint">
|
||||
Saved as a single PNG. Re-crop a side to update the merged file.
|
||||
</span>
|
||||
</div>
|
||||
<div class="kyc-merged__preview">
|
||||
<img [src]="idMergedPreview" alt="Merged ID document" />
|
||||
</div>
|
||||
<div class="kyc-merged__actions">
|
||||
<button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'front')">
|
||||
<i class="fa fa-crop"></i> Re-crop front
|
||||
</button>
|
||||
<button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'back')">
|
||||
<i class="fa fa-crop"></i> Re-crop back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- File section -->
|
||||
<div class="fd-section-divider">
|
||||
<span class="fd-section-divider__text">
|
||||
<i class="fa fa-paperclip"></i> {{ 'labels.heading.Documents' | translate }}
|
||||
<!-- SECTION 2: ADDRESS PROOF -->
|
||||
<div class="kyc-section" formGroupName="address">
|
||||
<div class="kyc-section__header">
|
||||
<div class="kyc-badge kyc-badge--teal">
|
||||
<i class="fa fa-map-marker"></i>
|
||||
</div>
|
||||
<div class="kyc-section__titles">
|
||||
<p class="kyc-section__title">
|
||||
<i class="fa fa-home"></i> Address proof
|
||||
</p>
|
||||
<p class="kyc-section__sub">
|
||||
Select document type — upload requirements will update automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prefilled permanent address -->
|
||||
@if (clientAddress) {
|
||||
<div class="kyc-prefill kyc-prefill--teal">
|
||||
<i class="fa fa-map-marker"></i>
|
||||
<div>
|
||||
<span class="kyc-prefill__label">Permanent address (from registration)</span>
|
||||
<span class="kyc-prefill__value">{{ clientAddress }}</span>
|
||||
</div>
|
||||
<i class="fa fa-lock kyc-prefill__lock" title="Prefilled from client registration"></i>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-banner kyc-banner--amber">
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
<span>No permanent address found on file. Add one in the Address tab first.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="kyc-field">
|
||||
<label class="kyc-label">
|
||||
<i class="fa fa-file-text-o"></i> Document type
|
||||
</label>
|
||||
<div class="kyc-chip-wrap">
|
||||
@for (group of addressGroups; track group.label; let last = $last) {
|
||||
<div class="kyc-chip-group-label">
|
||||
<i class="fa fa-folder-open-o"></i> {{ group.label }}
|
||||
</div>
|
||||
<div class="kyc-chip-grid">
|
||||
@for (p of group.items; track p.id) {
|
||||
<span
|
||||
class="kyc-chip"
|
||||
[class.kyc-chip--sel]="isAddrProofSelected(p)"
|
||||
(click)="selectAddrProof(p)"
|
||||
(keydown.enter)="selectAddrProof(p)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
@if (isAddrProofSelected(p)) {
|
||||
<i class="fa fa-check-circle"></i>
|
||||
} @else {
|
||||
<i class="fa fa-circle-o"></i>
|
||||
}
|
||||
{{ p.name }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (!last) {
|
||||
<div class="kyc-chip-divider"></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedAddrProof?.info) {
|
||||
<div class="kyc-banner kyc-banner--teal">
|
||||
<i class="fa fa-check-circle"></i>
|
||||
<span>{{ selectedAddrProof.info }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedAddrProof) {
|
||||
<div class="kyc-upload-grid" [class.kyc-upload-grid--two]="selectedAddrProof.sides === 2">
|
||||
<div class="kyc-field">
|
||||
<div class="kyc-upload-label">
|
||||
<span class="kyc-label">
|
||||
<i class="fa fa-file-image-o"></i>
|
||||
{{ selectedAddrProof.sides === 2 ? 'Front side' : (selectedAddrProof.name + ' (single copy)') }}
|
||||
</span>
|
||||
<span class="kyc-tag kyc-tag--req">
|
||||
<i class="fa fa-asterisk"></i> Required
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-dropzone"
|
||||
[class.kyc-dropzone--has]="addrFiles.front"
|
||||
[class.kyc-dropzone--req]="!addrFiles.front"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
hidden
|
||||
(change)="onFilePick('address', 'front', $event)"
|
||||
/>
|
||||
@if (addrFiles.front) {
|
||||
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">File selected</p>
|
||||
<p class="kyc-dropzone__name">
|
||||
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'front') }}
|
||||
</p>
|
||||
} @else {
|
||||
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">Click to upload</p>
|
||||
<p class="kyc-dropzone__hint">
|
||||
<i class="fa fa-file-image-o"></i> JPG ·
|
||||
<i class="fa fa-file-image-o"></i> PNG ·
|
||||
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (selectedAddrProof.sides === 2) {
|
||||
<div class="kyc-field">
|
||||
<div class="kyc-upload-label">
|
||||
<span class="kyc-label">
|
||||
<i class="fa fa-file-image-o"></i> Back side
|
||||
</span>
|
||||
<span class="kyc-tag kyc-tag--req">
|
||||
<i class="fa fa-asterisk"></i> Required
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
class="kyc-dropzone"
|
||||
[class.kyc-dropzone--has]="addrFiles.back"
|
||||
[class.kyc-dropzone--req]="!addrFiles.back"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf"
|
||||
hidden
|
||||
(change)="onFilePick('address', 'back', $event)"
|
||||
/>
|
||||
@if (addrFiles.back) {
|
||||
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">File selected</p>
|
||||
<p class="kyc-dropzone__name">
|
||||
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'back') }}
|
||||
</p>
|
||||
} @else {
|
||||
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
|
||||
<p class="kyc-dropzone__text">Click to upload</p>
|
||||
<p class="kyc-dropzone__hint">
|
||||
<i class="fa fa-file-image-o"></i> JPG ·
|
||||
<i class="fa fa-file-image-o"></i> PNG ·
|
||||
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedAddrProof.requiresDate) {
|
||||
<div class="kyc-field kyc-field--date">
|
||||
<label class="kyc-label">
|
||||
<i class="fa fa-calendar"></i> Issue / statement date
|
||||
<span class="kyc-opt">(not older than 3 months)</span>
|
||||
</label>
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
||||
<input
|
||||
type="date"
|
||||
class="kyc-input kyc-input--date"
|
||||
formControlName="issueDate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (addrMergedPreview && selectedAddrProof?.sides === 2) {
|
||||
<div class="kyc-merged">
|
||||
<div class="kyc-merged__head">
|
||||
<span class="kyc-merged__title">
|
||||
<i class="fa fa-object-group"></i> Combined preview (front + back)
|
||||
</span>
|
||||
<span class="kyc-merged__hint">
|
||||
Saved as a single PNG. Re-crop a side to update the merged file.
|
||||
</span>
|
||||
</div>
|
||||
<div class="kyc-merged__preview">
|
||||
<img [src]="addrMergedPreview" alt="Merged address document" />
|
||||
</div>
|
||||
<div class="kyc-merged__actions">
|
||||
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'front')">
|
||||
<i class="fa fa-crop"></i> Re-crop front
|
||||
</button>
|
||||
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'back')">
|
||||
<i class="fa fa-crop"></i> Re-crop back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kyc-section kyc-section--notes">
|
||||
<label class="kyc-label">
|
||||
<i class="fa fa-sticky-note-o"></i> Description <span class="kyc-opt">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="kyc-textarea"
|
||||
formControlName="description"
|
||||
placeholder="Add any additional notes about these documents…"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="kyc-status-bar">
|
||||
<span>
|
||||
<span class="kyc-dot" [class.kyc-dot--done]="isIdComplete()">
|
||||
@if (isIdComplete()) { <i class="fa fa-check"></i> }
|
||||
</span>
|
||||
<i class="fa fa-id-badge"></i> Identity proof
|
||||
</span>
|
||||
<span>
|
||||
<span class="kyc-dot" [class.kyc-dot--done]="isAddressComplete()">
|
||||
@if (isAddressComplete()) { <i class="fa fa-check"></i> }
|
||||
</span>
|
||||
<i class="fa fa-home"></i> Address proof
|
||||
</span>
|
||||
<span class="kyc-status-hint">
|
||||
<i class="fa fa-info-circle"></i> Fill in at least one section
|
||||
</span>
|
||||
</div>
|
||||
<div class="fd-grid fd-grid--cols-2">
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
<mat-label>{{ 'labels.inputs.File Name' | translate }}</mat-label>
|
||||
<input formControlName="fileName" required matInput />
|
||||
@if (uploadDocumentForm.controls.fileName.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.File Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="fd-field fd-field--file">
|
||||
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Standard upload form -->
|
||||
</form>
|
||||
} @else {
|
||||
<form [formGroup]="uploadDocumentForm" class="fd-form">
|
||||
<div class="fd-grid fd-grid--cols-2">
|
||||
<div class="fd-field">
|
||||
<mat-form-field class="flex-fill" appearance="outline">
|
||||
@@ -125,24 +479,25 @@
|
||||
<div class="fd-field fd-field--file">
|
||||
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions class="fd-actions" align="end">
|
||||
<button mat-stroked-button mat-dialog-close class="fd-btn fd-btn--cancel">
|
||||
<i class="fa fa-times fd-btn__icon"></i>
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!uploadDocumentForm.valid"
|
||||
[mat-dialog-close]="uploadDocumentForm.value"
|
||||
[disabled]="!canSubmit"
|
||||
(click)="submit()"
|
||||
class="fd-btn fd-btn--submit"
|
||||
>
|
||||
<i class="fa fa-check fd-btn__icon"></i>
|
||||
@if (documentIdentifier) {
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
} @else {
|
||||
{{ 'labels.buttons.Upload' | translate }}
|
||||
}
|
||||
|
||||
+690
-1
@@ -184,8 +184,697 @@ $white: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.fd-grid--cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== KYC Identifier Mode ===================== */
|
||||
|
||||
$blue-50: #e6f1fb;
|
||||
$blue-100: #d4e5f7;
|
||||
$blue-700: #185fa5;
|
||||
$blue-900: #0c447c;
|
||||
$teal-50: #e1f5ee;
|
||||
$teal-700: #0f6e56;
|
||||
$teal-900: #085041;
|
||||
$teal-accent: #1d9e75;
|
||||
$amber-50: #faeeda;
|
||||
$amber-700: #ba7517;
|
||||
$amber-900: #633806;
|
||||
$red-50: #faece7;
|
||||
$red-100: #f5c4b3;
|
||||
$red-700: #d85a30;
|
||||
$red-900: #993c1d;
|
||||
|
||||
.fd-dialog--kyc {
|
||||
.fd-content {
|
||||
padding: 18px 24px 8px !important;
|
||||
max-height: 75vh !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, $blue-50, $teal-50);
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 0 16px;
|
||||
|
||||
&__icon {
|
||||
font-size: 22px;
|
||||
color: $primary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: $gray-900;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: $gray-700;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
margin: 0 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-prefill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: $blue-50;
|
||||
border: 1px solid $blue-100;
|
||||
border-left: 3px solid $blue-700;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
> i:first-child {
|
||||
font-size: 16px;
|
||||
color: $blue-700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
color: $blue-900;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__lock {
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: $teal-50;
|
||||
border-color: $teal-50;
|
||||
border-left-color: $teal-700;
|
||||
|
||||
> i:first-child { color: $teal-700; }
|
||||
.kyc-prefill__value { color: $teal-900; }
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-input-wrap,
|
||||
.kyc-select-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $gray-500;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-select-wrap {
|
||||
&__chevron {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $gray-500;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kyc-select {
|
||||
padding-left: 36px;
|
||||
padding-right: 32px;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-input-wrap .kyc-input,
|
||||
.kyc-input.kyc-input--padded {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
/* Date input — match styling of other kyc inputs */
|
||||
.kyc-input--date {
|
||||
font-family: inherit;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
padding-right: 14px !important;
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:hover::-webkit-calendar-picker-indicator { opacity: 0.9; }
|
||||
|
||||
&::-webkit-datetime-edit {
|
||||
color: $gray-900;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-fields-wrapper { color: $gray-900; }
|
||||
&::-webkit-datetime-edit-text { color: $gray-500; padding: 0 2px; }
|
||||
|
||||
&:invalid::-webkit-datetime-edit,
|
||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notes section — textarea */
|
||||
.kyc-section--notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.kyc-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
background: $gray-50;
|
||||
color: $gray-900;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&::placeholder { color: $gray-500; }
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-loading {
|
||||
font-size: 13px;
|
||||
color: $gray-500;
|
||||
padding: 8px 0 14px;
|
||||
|
||||
i { margin-right: 6px; color: $primary; }
|
||||
}
|
||||
|
||||
.kyc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kyc-section {
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
&__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
i { color: $primary; font-size: 14px; }
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-badge {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--blue {
|
||||
background: $blue-50;
|
||||
color: $blue-700;
|
||||
border: 1px solid $blue-100;
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: $teal-50;
|
||||
color: $teal-700;
|
||||
border: 1px solid $teal-50;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&--2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-mb-sm { margin-bottom: 12px; }
|
||||
|
||||
.kyc-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kyc-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i {
|
||||
color: $gray-500;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-opt {
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.kyc-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
background: $gray-50;
|
||||
color: $gray-900;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&::placeholder { color: $gray-500; }
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%235f6368' d='M0 0l5 6 5-6z'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.kyc-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
|
||||
i { font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
&--blue {
|
||||
background: $blue-50;
|
||||
color: $blue-900;
|
||||
border-left: 3px solid $blue-700;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: $amber-50;
|
||||
color: $amber-900;
|
||||
border-left: 3px solid $amber-700;
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: $teal-50;
|
||||
color: $teal-900;
|
||||
border-left: 3px solid $teal-700;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
|
||||
&--two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-upload-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.kyc-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&--req {
|
||||
background: $red-50;
|
||||
color: $red-900;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-dropzone {
|
||||
display: block;
|
||||
border: 1.5px dashed $gray-200;
|
||||
border-radius: 8px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: $gray-50;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: $gray-100;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 20px;
|
||||
color: $gray-500;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: $gray-700;
|
||||
margin: 0 0 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 11px;
|
||||
color: $teal-900;
|
||||
font-weight: 600;
|
||||
margin: 6px 0 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&--has {
|
||||
border-color: $teal-accent;
|
||||
border-style: solid;
|
||||
background: $teal-50;
|
||||
|
||||
.kyc-dropzone__icon { color: $teal-accent; }
|
||||
.kyc-dropzone__text { color: $teal-900; }
|
||||
}
|
||||
|
||||
&--req {
|
||||
border-color: $red-700;
|
||||
background: $red-50;
|
||||
|
||||
&:hover {
|
||||
background: $red-100;
|
||||
}
|
||||
|
||||
.kyc-dropzone__icon { color: $red-700; }
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-chip-wrap {
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.kyc-chip-group-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $gray-500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i { color: $gray-500; font-size: 11px; }
|
||||
}
|
||||
|
||||
.kyc-chip-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kyc-chip-divider {
|
||||
height: 1px;
|
||||
background: $gray-200;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.kyc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
border: 1px solid $gray-200;
|
||||
background: $white;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
i { font-size: 11px; color: $gray-500; }
|
||||
|
||||
&:hover {
|
||||
border-color: $teal-700;
|
||||
color: $teal-900;
|
||||
|
||||
i { color: $teal-700; }
|
||||
}
|
||||
|
||||
&--sel {
|
||||
background: $teal-50;
|
||||
border-color: $teal-700;
|
||||
color: $teal-900;
|
||||
font-weight: 600;
|
||||
|
||||
i { color: $teal-accent; }
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
margin-top: 4px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid $gray-200;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kyc-status-hint {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.kyc-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
background: $gray-200;
|
||||
color: $white;
|
||||
transition: background 0.3s;
|
||||
font-size: 9px;
|
||||
|
||||
&--done {
|
||||
background: $teal-accent;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-status-bar {
|
||||
span i { color: $gray-500; margin: 0 2px; }
|
||||
|
||||
.kyc-status-hint i { color: $primary; }
|
||||
}
|
||||
|
||||
.kyc-merged {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 10px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0c4a6e;
|
||||
|
||||
i { margin-right: 6px; }
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #075985;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
display: inline-block;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
background: #fff;
|
||||
border: 1px solid #0284c7;
|
||||
color: #0284c7;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
i { font-size: 11px; }
|
||||
|
||||
&:hover {
|
||||
background: #0284c7;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 680px) {
|
||||
.kyc-row--2,
|
||||
.kyc-upload-grid--two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+467
-44
@@ -8,16 +8,150 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialog,
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -35,67 +169,66 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class UploadDocumentDialogComponent implements OnInit {
|
||||
dialogRef = inject<MatDialogRef<UploadDocumentDialogComponent>>(MatDialogRef);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** Upload Document form. */
|
||||
uploadDocumentForm: UntypedFormGroup;
|
||||
/** Upload Document Data */
|
||||
uploadDocumentData: any = [];
|
||||
/** Triggers identity fields (documentType, status, documentKey) */
|
||||
documentIdentifier = false;
|
||||
/** Entity Type */
|
||||
entityType: string;
|
||||
/** Allowed Document Types for identifiers */
|
||||
allowedDocumentTypes: any[] = [];
|
||||
/** Status options for identifiers */
|
||||
statusOptions: any[] = [];
|
||||
|
||||
/**
|
||||
* @param {MatDialogRef} dialogRef Dialog reference element
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
* @param {any} data Dialog Data
|
||||
*/
|
||||
/** Prefilled values from client registration (identifier mode) */
|
||||
clientName = '';
|
||||
clientAddress = '';
|
||||
|
||||
/** Identifier-mode state */
|
||||
idProofs: ProofType[] = [];
|
||||
addressProofs: ProofType[] = [];
|
||||
addressGroups: { label: string; items: ProofType[] }[] = [];
|
||||
loadingProofs = false;
|
||||
|
||||
selectedIdProof: ProofType | null = null;
|
||||
selectedAddrProof: ProofType | null = null;
|
||||
|
||||
idFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||
addrFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||
|
||||
/** Merged front+back PNG previews (data URLs) and File objects */
|
||||
idMergedFile: File | null = null;
|
||||
idMergedPreview: string | null = null;
|
||||
addrMergedFile: File | null = null;
|
||||
addrMergedPreview: string | null = null;
|
||||
|
||||
constructor() {
|
||||
const data = this.data;
|
||||
|
||||
this.documentIdentifier = data.documentIdentifier;
|
||||
this.entityType = data.entityType;
|
||||
this.allowedDocumentTypes = data.allowedDocumentTypes || [];
|
||||
this.statusOptions = data.statusOptions || [];
|
||||
this.documentIdentifier = !!data?.documentIdentifier;
|
||||
this.entityType = data?.entityType;
|
||||
this.clientName = data?.clientName || '';
|
||||
this.clientAddress = data?.clientAddress || '';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.createUploadDocumentForm();
|
||||
if (this.documentIdentifier) {
|
||||
this.loadProofTypes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the upload Document form.
|
||||
*/
|
||||
createUploadDocumentForm() {
|
||||
if (this.documentIdentifier) {
|
||||
// Unified form for identity: identifier fields + document upload
|
||||
this.uploadDocumentForm = this.formBuilder.group({
|
||||
documentTypeId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
status: [
|
||||
'Active',
|
||||
Validators.required
|
||||
],
|
||||
documentKey: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
description: [''],
|
||||
fileName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
file: ['']
|
||||
id: this.formBuilder.group({
|
||||
proofId: [''],
|
||||
documentKey: ['']
|
||||
}),
|
||||
address: this.formBuilder.group({
|
||||
proofId: [''],
|
||||
issueDate: ['']
|
||||
}),
|
||||
description: ['']
|
||||
});
|
||||
} else {
|
||||
// Standard document upload form
|
||||
this.uploadDocumentForm = this.formBuilder.group({
|
||||
fileName: [
|
||||
'',
|
||||
@@ -107,10 +240,300 @@ export class UploadDocumentDialogComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private loadProofTypes(): void {
|
||||
this.loadingProofs = true;
|
||||
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.loadingProofs = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loadingProofs = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
this.idMergedFile = null;
|
||||
this.idMergedPreview = null;
|
||||
}
|
||||
|
||||
selectAddrProof(proof: ProofType): void {
|
||||
this.selectedAddrProof = proof;
|
||||
this.uploadDocumentForm.get('address.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;
|
||||
}
|
||||
|
||||
onFilePick(section: SectionKey, slot: 'front' | 'back', event: any): void {
|
||||
const file = event?.target?.files?.[0];
|
||||
// reset the input so picking the same file again still triggers change
|
||||
if (event?.target) event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
// Non-image (e.g. PDF) — bypass cropping
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.setSlotFile(section, slot, file);
|
||||
this.maybeMerge(section);
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = this.dialog.open(ImageCropperDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
data: { file, title: `Crop ${slot} side` }
|
||||
});
|
||||
|
||||
ref.afterClosed().subscribe((cropped: File | null) => {
|
||||
if (!cropped) return;
|
||||
this.setSlotFile(section, slot, cropped);
|
||||
this.maybeMerge(section);
|
||||
});
|
||||
}
|
||||
|
||||
reCrop(section: SectionKey, slot: 'front' | 'back'): void {
|
||||
const current = section === 'id' ? this.idFiles[slot] : this.addrFiles[slot];
|
||||
if (!current || !current.type.startsWith('image/')) return;
|
||||
const ref = this.dialog.open(ImageCropperDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
data: { file: current, title: `Re-crop ${slot} side` }
|
||||
});
|
||||
ref.afterClosed().subscribe((cropped: File | null) => {
|
||||
if (!cropped) return;
|
||||
this.setSlotFile(section, slot, cropped);
|
||||
this.maybeMerge(section);
|
||||
});
|
||||
}
|
||||
|
||||
private setSlotFile(section: SectionKey, slot: 'front' | 'back', file: File): void {
|
||||
if (section === 'id') this.idFiles[slot] = file;
|
||||
else this.addrFiles[slot] = file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets file form control value.
|
||||
* @param {any} $event file change event.
|
||||
* If a section has both image sides ready, build a single merged PNG
|
||||
* (front stacked above back) and store it as the file we'll actually upload.
|
||||
*/
|
||||
private maybeMerge(section: SectionKey): void {
|
||||
const proof = section === 'id' ? this.selectedIdProof : this.selectedAddrProof;
|
||||
if (!proof || proof.sides !== 2) {
|
||||
this.resetMerged(section);
|
||||
return;
|
||||
}
|
||||
const front = section === 'id' ? this.idFiles.front : this.addrFiles.front;
|
||||
const back = section === 'id' ? this.idFiles.back : this.addrFiles.back;
|
||||
if (!front || !back) {
|
||||
this.resetMerged(section);
|
||||
return;
|
||||
}
|
||||
if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) {
|
||||
// Cannot merge non-image files (PDFs); leave as separate.
|
||||
this.resetMerged(section);
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const merged = new File([blob], `${proof.name || 'document'}-combined.png`, { type: 'image/png' });
|
||||
const preview = canvas.toDataURL('image/png');
|
||||
if (section === 'id') {
|
||||
this.idMergedFile = merged;
|
||||
this.idMergedPreview = preview;
|
||||
} else {
|
||||
this.addrMergedFile = merged;
|
||||
this.addrMergedPreview = preview;
|
||||
}
|
||||
}, 'image/png');
|
||||
})
|
||||
.catch(() => this.resetMerged(section));
|
||||
}
|
||||
|
||||
private resetMerged(section: SectionKey): void {
|
||||
if (section === 'id') {
|
||||
this.idMergedFile = null;
|
||||
this.idMergedPreview = null;
|
||||
} else {
|
||||
this.addrMergedFile = null;
|
||||
this.addrMergedPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
getFileName(section: SectionKey, slot: 'front' | 'back'): string {
|
||||
const f = section === 'id' ? this.idFiles[slot] : this.addrFiles[slot];
|
||||
if (!f) return '';
|
||||
return f.name.length > 30 ? f.name.slice(0, 27) + '...' : f.name;
|
||||
}
|
||||
|
||||
isIdComplete(): boolean {
|
||||
return this.isSectionComplete('id');
|
||||
}
|
||||
|
||||
isAddressComplete(): boolean {
|
||||
return this.isSectionComplete('address');
|
||||
}
|
||||
|
||||
/** Section is "filled in" — has a proof selected */
|
||||
private isSectionTouched(section: SectionKey): boolean {
|
||||
return section === 'id' ? !!this.selectedIdProof : !!this.selectedAddrProof;
|
||||
}
|
||||
|
||||
/** Section has all required fields + files */
|
||||
private isSectionComplete(section: SectionKey): boolean {
|
||||
if (section === 'id') {
|
||||
const proof = this.selectedIdProof;
|
||||
if (!proof) return false;
|
||||
const v = this.uploadDocumentForm.get('id').value;
|
||||
if (!v.documentKey) return false;
|
||||
if (!this.idFiles.front) return false;
|
||||
if (proof.sides === 2 && !this.idFiles.back) return false;
|
||||
return true;
|
||||
}
|
||||
const proof = this.selectedAddrProof;
|
||||
if (!proof) return false;
|
||||
if (!this.addrFiles.front) return false;
|
||||
if (proof.sides === 2 && !this.addrFiles.back) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
if (!this.documentIdentifier) {
|
||||
return this.uploadDocumentForm?.valid;
|
||||
}
|
||||
const idTouched = this.isSectionTouched('id');
|
||||
const addrTouched = this.isSectionTouched('address');
|
||||
if (!idTouched && !addrTouched) return false;
|
||||
if (idTouched && !this.isSectionComplete('id')) return false;
|
||||
if (addrTouched && !this.isSectionComplete('address')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.documentIdentifier) {
|
||||
this.dialogRef.close(this.uploadDocumentForm.value);
|
||||
return;
|
||||
}
|
||||
const result: any = { description: this.uploadDocumentForm.get('description').value, id: null, address: null };
|
||||
if (this.isSectionComplete('id')) {
|
||||
const v = this.uploadDocumentForm.get('id').value;
|
||||
result.id = {
|
||||
proof: this.selectedIdProof,
|
||||
proofId: this.selectedIdProof.id,
|
||||
documentKey: v.documentKey,
|
||||
fullName: this.clientName,
|
||||
files: this.buildSectionFiles('id')
|
||||
};
|
||||
}
|
||||
if (this.isSectionComplete('address')) {
|
||||
const v = this.uploadDocumentForm.get('address').value;
|
||||
result.address = {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.selectedAddrProof.id,
|
||||
address: this.clientAddress,
|
||||
issueDate: v.issueDate,
|
||||
files: this.buildSectionFiles('address')
|
||||
};
|
||||
}
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the files payload for a section. When both sides are images and
|
||||
* have been merged into a single combined PNG, send only that one file.
|
||||
* Otherwise fall back to the individual front/back files.
|
||||
*/
|
||||
private buildSectionFiles(section: SectionKey): { slot: string; file: File }[] {
|
||||
const proof = section === 'id' ? this.selectedIdProof : this.selectedAddrProof;
|
||||
const front = section === 'id' ? this.idFiles.front : this.addrFiles.front;
|
||||
const back = section === 'id' ? this.idFiles.back : this.addrFiles.back;
|
||||
const merged = section === 'id' ? this.idMergedFile : this.addrMergedFile;
|
||||
|
||||
if (proof?.sides === 2 && merged) {
|
||||
return [{ slot: 'Combined', file: merged }];
|
||||
}
|
||||
|
||||
const files: { slot: string; file: File }[] = [];
|
||||
if (front) files.push({ slot: 'Front', file: front });
|
||||
if (proof?.sides === 2 && back) files.push({ slot: 'Back', file: back });
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Legacy: kept for non-identifier mode file upload component */
|
||||
onFileSelect($event: any) {
|
||||
if ($event.target.files.length > 0) {
|
||||
const file = $event.target.files[0];
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
.ec-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 { font-size: 16px; font-weight: 600; color: #1e293b; display: flex; align-items: center; gap: 8px;
|
||||
fa-icon { color: #1565c0; }
|
||||
}
|
||||
@@ -55,6 +56,6 @@
|
||||
.ec-card__label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; font-weight: 500; }
|
||||
.ec-card__value { font-size: 13px; font-weight: 500; color: #1e293b; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.ec-card__grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ $orange-light: #fff3e0;
|
||||
margin-bottom: 12px !important;
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid $gray-200 !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 6%) !important;
|
||||
|
||||
&::ng-deep .mat-expansion-panel-body {
|
||||
padding: 0 20px 20px !important;
|
||||
@@ -224,11 +224,11 @@ $orange-light: #fff3e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (width <= 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ $orange-light: #fff3e0;
|
||||
word-break: break-word;
|
||||
|
||||
&--mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ $white: #fff;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.fd-grid--cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ $bg: #F0F4F8;
|
||||
}
|
||||
&__icon { font-size: 40px; width: 40px; height: 40px; color: $green; }
|
||||
&__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; }
|
||||
|
||||
&__desc {
|
||||
font-size: 15px; color: $text; margin: 0 0 6px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap;
|
||||
@@ -67,10 +68,12 @@ $bg: #F0F4F8;
|
||||
h3 { margin: 0; font-size: 15px; font-weight: 600; color: $text; flex: 1; }
|
||||
}
|
||||
&__header-icon { color: $primary; font-size: 22px; }
|
||||
|
||||
&__status {
|
||||
font-size: 11px; font-weight: 600; color: $green; background: $green-light;
|
||||
padding: 3px 10px; border-radius: 12px;
|
||||
}
|
||||
|
||||
&__edit {
|
||||
font-size: 12px !important; height: 30px !important; border-radius: 6px !important;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; margin-right: 4px; }
|
||||
@@ -81,11 +84,14 @@ $bg: #F0F4F8;
|
||||
/* ── Detail Grid (read-only) ── */
|
||||
.f60-detail-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
|
||||
@media (width <= 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.f60-detail {
|
||||
display: flex; flex-direction: column;
|
||||
&--full { grid-column: 1 / -1; }
|
||||
|
||||
&__label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: $muted; letter-spacing: 0.5px; margin-bottom: 4px;
|
||||
@@ -98,7 +104,8 @@ $bg: #F0F4F8;
|
||||
/* ── Form Grid ── */
|
||||
.f60-form-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
|
||||
@media (width <= 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
.f60-ff { width: 100%; &--full { grid-column: 1 / -1; } }
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ $gray-300: #dadce0;
|
||||
$gray-600: #5f6368;
|
||||
$gray-700: #3c4043;
|
||||
$gray-900: #202124;
|
||||
$white: #ffffff;
|
||||
$white: #fff;
|
||||
$red: #d93025;
|
||||
|
||||
// ── Main Container ──
|
||||
@@ -41,7 +41,7 @@ $red: #d93025;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ $red: #d93025;
|
||||
background: $white;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 6%);
|
||||
border-left: 4px solid transparent;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ $red: #d93025;
|
||||
// Color variants
|
||||
&--blue {
|
||||
border-left-color: $primary-blue;
|
||||
|
||||
.stat-card__icon {
|
||||
background: $primary-blue-light;
|
||||
color: $primary-blue;
|
||||
@@ -114,6 +115,7 @@ $red: #d93025;
|
||||
|
||||
&--green {
|
||||
border-left-color: $green;
|
||||
|
||||
.stat-card__icon {
|
||||
background: $green-light;
|
||||
color: $green;
|
||||
@@ -122,6 +124,7 @@ $red: #d93025;
|
||||
|
||||
&--purple {
|
||||
border-left-color: $purple;
|
||||
|
||||
.stat-card__icon {
|
||||
background: $purple-light;
|
||||
color: $purple;
|
||||
@@ -130,6 +133,7 @@ $red: #d93025;
|
||||
|
||||
&--teal {
|
||||
border-left-color: $teal;
|
||||
|
||||
.stat-card__icon {
|
||||
background: $teal-light;
|
||||
color: $teal;
|
||||
@@ -138,6 +142,7 @@ $red: #d93025;
|
||||
|
||||
&--orange {
|
||||
border-left-color: $orange;
|
||||
|
||||
.stat-card__icon {
|
||||
background: $orange-light;
|
||||
color: $orange;
|
||||
@@ -204,7 +209,7 @@ $red: #d93025;
|
||||
.table-wrapper {
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
|
||||
border: 1px solid $gray-200;
|
||||
border-top: none;
|
||||
|
||||
@@ -370,7 +375,7 @@ table[mat-table] {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgb(0 0 0 / 50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -384,7 +389,7 @@ table[mat-table] {
|
||||
width: 85%;
|
||||
max-width: 900px;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.pdf-modal-close {
|
||||
|
||||
@@ -46,19 +46,17 @@
|
||||
|
||||
<!-- Card Body -->
|
||||
<div class="idt-card__body">
|
||||
<div class="idt-grid">
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Id' | translate }}</div>
|
||||
<div class="idt-field__value idt-field__value--mono">{{ identity.id }}</div>
|
||||
</div>
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Document Key' | translate }}</div>
|
||||
<div class="idt-field__value idt-field__value--mono">{{ identity.documentKey }}</div>
|
||||
<div class="idt-meta">
|
||||
<div class="idt-meta__row">
|
||||
<i class="fa fa-key idt-meta__icon"></i>
|
||||
<span class="idt-meta__label">{{ 'labels.inputs.Document Key' | translate }}</span>
|
||||
<span class="idt-meta__value idt-meta__value--mono">{{ identity.documentKey || '—' }}</span>
|
||||
</div>
|
||||
@if (identity.description) {
|
||||
<div class="idt-field">
|
||||
<div class="idt-field__label">{{ 'labels.inputs.Description' | translate }}</div>
|
||||
<div class="idt-field__value">{{ identity.description }}</div>
|
||||
<div class="idt-meta__row">
|
||||
<i class="fa fa-sticky-note-o idt-meta__icon"></i>
|
||||
<span class="idt-meta__label">{{ 'labels.inputs.Description' | translate }}</span>
|
||||
<span class="idt-meta__value">{{ identity.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -68,6 +66,7 @@
|
||||
<div class="idt-docs">
|
||||
<div class="idt-docs__title">
|
||||
<i class="fa fa-paperclip"></i> {{ 'labels.inputs.Identity Documents' | translate }}
|
||||
<span class="idt-docs__count">{{ identity.documents.length }}</span>
|
||||
</div>
|
||||
<div class="idt-docs__grid">
|
||||
@for (document of identity.documents; track trackByDocumentId($index, document)) {
|
||||
@@ -80,13 +79,15 @@
|
||||
<img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" />
|
||||
} @else {
|
||||
<div class="idt-doc__placeholder">
|
||||
<i class="fa fa-file-o"></i>
|
||||
<i class="fa fa-file-text-o"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="idt-doc__info">
|
||||
<span class="idt-doc__name">{{ document.name }}</span>
|
||||
@if (document.fileName) {
|
||||
<span class="idt-doc__file">{{ document.fileName }}</span>
|
||||
<span class="idt-doc__file">
|
||||
<i class="fa fa-paperclip"></i> {{ document.fileName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isPreviewable(document)) {
|
||||
|
||||
@@ -79,190 +79,284 @@ $red-light: #fce8e6;
|
||||
.idt-card {
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: $primary;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-light;
|
||||
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.08);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
background: $gray-50;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
padding: 14px 18px 14px 22px;
|
||||
background: linear-gradient(to right, $primary-light 0%, $gray-50 40%, $white 100%);
|
||||
border-bottom: 1px solid $gray-100;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__type-icon {
|
||||
font-size: 14px;
|
||||
color: $primary;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 15px;
|
||||
color: $white;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $primary-light;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, $primary, #4285f4);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&--active { background: $green-light; color: $green; }
|
||||
&--inactive { background: $red-light; color: $red; }
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: $green-light;
|
||||
color: $green;
|
||||
|
||||
&::before { background: $green; }
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
background: $red-light;
|
||||
color: $red;
|
||||
|
||||
&::before { background: $red; }
|
||||
}
|
||||
}
|
||||
|
||||
&__delete {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
min-width: 30px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 6px !important;
|
||||
border-radius: 8px !important;
|
||||
border-color: $gray-200 !important;
|
||||
color: $gray-500 !important;
|
||||
background: $white !important;
|
||||
|
||||
&:hover { color: $red !important; border-color: $red !important; background: $red-light !important; }
|
||||
&:hover {
|
||||
color: $red !important;
|
||||
border-color: $red !important;
|
||||
background: $red-light !important;
|
||||
}
|
||||
|
||||
i { font-size: 12px; }
|
||||
i { font-size: 13px; }
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 14px 18px;
|
||||
padding: 16px 18px 18px 22px;
|
||||
}
|
||||
}
|
||||
|
||||
// Details grid
|
||||
.idt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
border: 1px solid $gray-100;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
// Metadata rows (compact, no grid)
|
||||
.idt-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: $gray-50;
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 14px;
|
||||
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px dashed $gray-200;
|
||||
|
||||
.idt-field {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
border-right: 1px solid $gray-100;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
&:nth-child(3n) { border-right: none; }
|
||||
&:hover { background: $gray-50; }
|
||||
&__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $primary;
|
||||
font-size: 12px;
|
||||
background: $primary-light;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 2px;
|
||||
min-width: 95px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $gray-900;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&--mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
&__row { flex-wrap: wrap; }
|
||||
&__label { min-width: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
// Documents section
|
||||
.idt-docs {
|
||||
margin-top: 12px;
|
||||
margin-top: 4px;
|
||||
|
||||
&__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: $gray-500;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
i { color: $primary; margin-right: 4px; }
|
||||
i { color: $primary; }
|
||||
}
|
||||
|
||||
&__count {
|
||||
background: $primary-light;
|
||||
color: $primary;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.idt-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-100;
|
||||
border-radius: 6px;
|
||||
transition: all 0.12s;
|
||||
min-width: 180px;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: $primary-light;
|
||||
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 1px solid $gray-200;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $gray-200;
|
||||
border-radius: 4px;
|
||||
color: $gray-500;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, $primary-light, $gray-100);
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 6px;
|
||||
color: $primary;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
&__file {
|
||||
@@ -271,15 +365,27 @@ $red-light: #fce8e6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
i { font-size: 9px; }
|
||||
}
|
||||
|
||||
&__preview {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
margin-left: auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $primary-light;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover .idt-doc__preview { opacity: 1; }
|
||||
|
||||
@@ -86,6 +86,10 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
clientIdentifierTemplate: any;
|
||||
/** Client Id */
|
||||
clientId: string;
|
||||
/** Client display name (prefilled into dialog) */
|
||||
clientName = '';
|
||||
/** Formatted permanent address (prefilled into dialog) */
|
||||
clientAddress = '';
|
||||
/** Identities Columns */
|
||||
identitiesColumns: string[] = [
|
||||
'id',
|
||||
@@ -126,6 +130,39 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
this.clientIdentifierTemplate = data.clientIdentifierTemplate;
|
||||
this.prefetchThumbnails();
|
||||
});
|
||||
|
||||
// Pull display name from parent route's clientViewData
|
||||
this.route.parent.data
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((data: any) => {
|
||||
this.clientName = data?.clientViewData?.displayName || '';
|
||||
});
|
||||
|
||||
// Pull permanent address (falls back to first available address)
|
||||
this.clientService.getClientAddressData(this.clientId).subscribe({
|
||||
next: (addresses: any[]) => {
|
||||
if (!addresses?.length) return;
|
||||
const permanent =
|
||||
addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0];
|
||||
this.clientAddress = this.formatAddress(permanent);
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
private formatAddress(addr: any): string {
|
||||
if (!addr) return '';
|
||||
return [
|
||||
addr.addressLine1,
|
||||
addr.addressLine2,
|
||||
addr.townVillage,
|
||||
addr.city,
|
||||
addr.stateName,
|
||||
addr.countryName,
|
||||
addr.postalCode
|
||||
]
|
||||
.filter((p) => !!p && String(p).trim())
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -143,97 +180,97 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Client Identifier with unified form (identifier + document upload)
|
||||
* Add Client Identifier — opens KYC dialog that may create up to two
|
||||
* identifiers (identity proof + address proof), each with front/back docs.
|
||||
*/
|
||||
addIdentifier() {
|
||||
// Translate document type names
|
||||
const translatedDocTypes = this.clientIdentifierTemplate.allowedDocumentTypes.map((docType: any) => ({
|
||||
...docType,
|
||||
name: this.translateService.instant(`labels.catalogs.${docType.name}`)
|
||||
}));
|
||||
|
||||
const statusOptions = [
|
||||
{ label: this.translateService.instant('labels.catalogs.Active'), value: 'Active' },
|
||||
{ label: this.translateService.instant('labels.catalogs.Inactive'), value: 'Inactive' }
|
||||
];
|
||||
|
||||
const dialogRef = this.dialog.open(UploadDocumentDialogComponent, {
|
||||
width: '700px',
|
||||
width: '880px',
|
||||
maxWidth: '95vw',
|
||||
data: {
|
||||
documentIdentifier: true,
|
||||
allowedDocumentTypes: translatedDocTypes,
|
||||
statusOptions: statusOptions
|
||||
clientName: this.clientName,
|
||||
clientAddress: this.clientAddress
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response) {
|
||||
// Create identifier data
|
||||
const identifierData = {
|
||||
documentTypeId: response.documentTypeId,
|
||||
status: response.status,
|
||||
documentKey: response.documentKey,
|
||||
description: response.description
|
||||
if (!response) return;
|
||||
const description = response.description || '';
|
||||
if (response.id) {
|
||||
this.createIdentifierWithDocs(response.id, description, 'id');
|
||||
}
|
||||
if (response.address) {
|
||||
this.createIdentifierWithDocs(response.address, description, 'address');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one identifier from a KYC section payload, then upload each file
|
||||
* as a document attached to that identifier.
|
||||
*/
|
||||
private createIdentifierWithDocs(section: any, description: string, kind: 'id' | 'address'): void {
|
||||
const identifierData = {
|
||||
documentTypeId: section.proofId,
|
||||
status: 'Active',
|
||||
documentKey:
|
||||
kind === 'id'
|
||||
? section.documentKey
|
||||
: `${section.proof?.name || 'Address Proof'} - ${this.clientId}`,
|
||||
description: kind === 'address' && section.address ? section.address : description
|
||||
};
|
||||
|
||||
this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const newIdentifierId = res.resourceId;
|
||||
const newIdentity: any = {
|
||||
id: newIdentifierId,
|
||||
description,
|
||||
documentType: { id: section.proof?.id, name: section.proof?.name },
|
||||
documentKey: identifierData.documentKey,
|
||||
documents: [] as any[],
|
||||
clientId: this.clientId,
|
||||
status: 'clientIdentifierStatusType.active'
|
||||
};
|
||||
|
||||
// First create the identifier
|
||||
this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const newIdentifierId = res.resourceId;
|
||||
const selectedDocType = this.clientIdentifierTemplate.allowedDocumentTypes.find(
|
||||
(doc: any) => doc.id === response.documentTypeId
|
||||
);
|
||||
const files: { slot: string; file: File }[] = section.files || [];
|
||||
if (!files.length) {
|
||||
this.clientIdentities.push(newIdentity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new identity entry
|
||||
const newIdentity: any = {
|
||||
id: newIdentifierId,
|
||||
description: response.description,
|
||||
documentType: selectedDocType,
|
||||
documentKey: response.documentKey,
|
||||
documents: [] as any[],
|
||||
clientId: this.clientId,
|
||||
status:
|
||||
response.status === 'Active'
|
||||
? 'clientIdentifierStatusType.active'
|
||||
: 'clientIdentifierStatusType.inactive'
|
||||
};
|
||||
|
||||
// If file was uploaded, attach document to the identifier
|
||||
if (response.file) {
|
||||
const formData: FormData = new FormData();
|
||||
formData.append('name', response.fileName);
|
||||
formData.append('file', response.file);
|
||||
this.clientService.uploadClientIdentifierDocument(newIdentifierId, formData).subscribe({
|
||||
next: (docRes: any) => {
|
||||
const newDoc = {
|
||||
id: docRes.resourceId,
|
||||
parentEntityType: 'client_identifiers',
|
||||
parentEntityId: newIdentifierId,
|
||||
name: response.fileName,
|
||||
fileName: response.file.name
|
||||
};
|
||||
newIdentity.documents.push(newDoc);
|
||||
this.clientIdentities.push(newIdentity);
|
||||
// Array mutation triggers @for re-render
|
||||
this.setThumbnail(newDoc);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to upload document', err);
|
||||
// Still add the identifier even if document upload fails
|
||||
this.clientIdentities.push(newIdentity);
|
||||
// Array mutation triggers @for re-render
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No file, just add the identifier
|
||||
this.clientIdentities.push(newIdentity);
|
||||
// Array mutation triggers @for re-render
|
||||
let pending = files.length;
|
||||
files.forEach((entry) => {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${section.proof?.name || 'Document'} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientService.uploadClientIdentifierDocument(newIdentifierId, formData).subscribe({
|
||||
next: (docRes: any) => {
|
||||
const newDoc = {
|
||||
id: docRes.resourceId,
|
||||
parentEntityType: 'client_identifiers',
|
||||
parentEntityId: newIdentifierId,
|
||||
name: `${section.proof?.name || 'Document'} — ${entry.slot}`,
|
||||
fileName: entry.file.name
|
||||
};
|
||||
newIdentity.documents.push(newDoc);
|
||||
this.setThumbnail(newDoc);
|
||||
if (--pending === 0) {
|
||||
this.clientIdentities.push(newIdentity);
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to upload document', err);
|
||||
if (--pending === 0) {
|
||||
this.clientIdentities.push(newIdentity);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to create identifier', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to create identifier', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -245,14 +282,16 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
* @param {number} index Index
|
||||
*/
|
||||
deleteIdentifier(clientId: string, identifierId: string, index: number) {
|
||||
const identity = this.clientIdentities?.[index];
|
||||
const typeName = identity?.documentType?.name || this.translateService.instant('labels.heading.identifier id');
|
||||
const key = identity?.documentKey ? ` (${identity.documentKey})` : '';
|
||||
const deleteIdentifierDialogRef = this.dialog.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `${this.translateService.instant('labels.heading.identifier id')} : ${identifierId}` }
|
||||
data: { deleteContext: `${typeName}${key}` }
|
||||
});
|
||||
deleteIdentifierDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.delete) {
|
||||
this.clientService.deleteClientIdentifier(clientId, identifierId).subscribe((res) => {
|
||||
if (response?.delete) {
|
||||
this.clientService.deleteClientIdentifier(clientId, identifierId).subscribe(() => {
|
||||
this.clientIdentities.splice(index, 1);
|
||||
// Array mutation triggers @for re-render
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,10 +27,17 @@
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="toggleEditMode()" type="button">
|
||||
<fa-icon [icon]="isEditMode ? 'times' : 'edit'" class="m-r-10"></fa-icon>
|
||||
{{ isEditMode ? ('labels.buttons.Cancel' | translate) : ('labels.buttons.Edit' | translate) }}
|
||||
</button>
|
||||
<div class="kyc-header-actions">
|
||||
@if (!isEditMode && kycData) {
|
||||
<button mat-stroked-button class="kyc-print-btn" type="button" (click)="printSelfDeclaration()">
|
||||
<fa-icon icon="file-pdf" class="m-r-6"></fa-icon> Self Declaration
|
||||
</button>
|
||||
}
|
||||
<button mat-raised-button color="primary" (click)="toggleEditMode()" type="button">
|
||||
<fa-icon [icon]="isEditMode ? 'times' : 'edit'" class="m-r-10"></fa-icon>
|
||||
{{ isEditMode ? ('labels.buttons.Cancel' | translate) : ('labels.buttons.Edit' | translate) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@@ -255,6 +262,100 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Re-KYC: Upload Signed Declaration & Complete -->
|
||||
<div class="rekyc-section">
|
||||
<div class="rekyc-header">
|
||||
<div class="rekyc-icon-wrap">
|
||||
<fa-icon icon="sync-alt" class="rekyc-icon"></fa-icon>
|
||||
</div>
|
||||
<div class="rekyc-header-text">
|
||||
<h4 class="rekyc-title">Re-KYC Completion</h4>
|
||||
<p class="rekyc-desc">Print self-declaration, get it signed, upload & complete re-KYC.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="rekyc-timeline">
|
||||
<div class="rekyc-tl-item">
|
||||
<div class="rekyc-tl-dot">
|
||||
<span class="rekyc-tl-num">1</span>
|
||||
<div class="rekyc-tl-line"></div>
|
||||
</div>
|
||||
<div class="rekyc-tl-content">
|
||||
<div class="rekyc-tl-label">Download Self Declaration Form</div>
|
||||
<div class="rekyc-tl-sub">System-generated PDF with PAN, Aadhaar & KYC details</div>
|
||||
<button mat-flat-button class="rekyc-btn-print" type="button" (click)="printSelfDeclaration()">
|
||||
<fa-icon icon="file-pdf"></fa-icon> Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="rekyc-tl-item">
|
||||
<div class="rekyc-tl-dot">
|
||||
<span class="rekyc-tl-num">2</span>
|
||||
<div class="rekyc-tl-line"></div>
|
||||
</div>
|
||||
<div class="rekyc-tl-content">
|
||||
<div class="rekyc-tl-label">Upload Signed Declaration</div>
|
||||
<div class="rekyc-tl-sub">Get the form signed by the client and upload the scanned copy</div>
|
||||
<label class="rekyc-upload-zone" [class.has-file]="selectedDeclarationFile">
|
||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" (change)="onDeclarationFileSelected($event)" hidden />
|
||||
@if (!selectedDeclarationFile) {
|
||||
<mat-icon class="upload-zone-icon">cloud_upload</mat-icon>
|
||||
<span class="upload-zone-text">Click to upload signed form</span>
|
||||
<span class="upload-zone-hint">PDF, JPG or PNG</span>
|
||||
} @else {
|
||||
<fa-icon icon="check-circle" class="upload-zone-check"></fa-icon>
|
||||
<span class="upload-zone-filename">{{ selectedDeclarationFile.name }}</span>
|
||||
<span class="upload-zone-hint">Click to change file</span>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="rekyc-tl-item rekyc-tl-item--last">
|
||||
<div class="rekyc-tl-dot">
|
||||
<span class="rekyc-tl-num">3</span>
|
||||
</div>
|
||||
<div class="rekyc-tl-content">
|
||||
<div class="rekyc-tl-label">Complete Re-KYC</div>
|
||||
<div class="rekyc-tl-sub">Updates Last Verified date and sets new Expiry Date as per RBI norms</div>
|
||||
<button mat-flat-button color="primary" class="rekyc-btn-complete" type="button"
|
||||
[disabled]="!selectedDeclarationFile || reKycCompleting || !kycId"
|
||||
(click)="completeReKyc()">
|
||||
@if (reKycCompleting) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
Completing...
|
||||
} @else {
|
||||
<fa-icon icon="check-circle"></fa-icon> Complete Re-KYC
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (reKycSuccess) {
|
||||
<div class="rekyc-result rekyc-result--success">
|
||||
<fa-icon icon="check-circle" class="rekyc-result-icon"></fa-icon>
|
||||
<div>
|
||||
<strong>Re-KYC completed successfully</strong>
|
||||
<p>Last Verified and Expiry Date have been updated.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (reKycError) {
|
||||
<div class="rekyc-result rekyc-result--error">
|
||||
<mat-icon class="rekyc-result-icon">error_outline</mat-icon>
|
||||
<div>
|
||||
<strong>Re-KYC failed</strong>
|
||||
<p>{{ reKycError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- No KYC Data State -->
|
||||
|
||||
@@ -612,7 +612,7 @@
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (width <= 960px) {
|
||||
.kv-table__col {
|
||||
&--doc { flex: 0 0 120px; }
|
||||
&--date { display: none; }
|
||||
@@ -623,9 +623,281 @@
|
||||
.kv-meta-item:nth-child(3n) { border-right: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (width <= 640px) {
|
||||
.kv-table__row { flex-wrap: wrap; }
|
||||
.kv-table__col { &--status, &--action { flex: 0 0 50%; } }
|
||||
.kv-meta-grid { grid-template-columns: 1fr 1fr; }
|
||||
.kv-otp-bar { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ─── Header Actions ─── */
|
||||
.kyc-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.kyc-print-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1074b9 !important;
|
||||
border-color: #93c5fd !important;
|
||||
}
|
||||
|
||||
.m-r-6 { margin-right: 6px; }
|
||||
|
||||
/* ─── Re-KYC Section ─── */
|
||||
.rekyc-section {
|
||||
margin-top: 20px;
|
||||
background: linear-gradient(135deg, #fafbff 0%, #f0f4ff 100%);
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.rekyc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.rekyc-icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #1074b9, #0c5da0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rekyc-icon {
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rekyc-header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rekyc-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.rekyc-desc {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─── Timeline Layout ─── */
|
||||
.rekyc-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.rekyc-tl-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rekyc-tl-dot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.rekyc-tl-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #1074b9;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rekyc-tl-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
background: #c7d8ed;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.rekyc-tl-item--last .rekyc-tl-dot {
|
||||
.rekyc-tl-line { display: none; }
|
||||
}
|
||||
|
||||
.rekyc-tl-content {
|
||||
flex: 1;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.rekyc-tl-item--last .rekyc-tl-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.rekyc-tl-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.rekyc-tl-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.rekyc-btn-print {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1074b9 !important;
|
||||
background: #fff !important;
|
||||
border: 1px solid #bfdbfe !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 6px 16px !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff !important;
|
||||
border-color: #93c5fd !important;
|
||||
}
|
||||
}
|
||||
|
||||
.rekyc-btn-complete {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 7px 20px !important;
|
||||
}
|
||||
|
||||
/* ─── Upload Zone ─── */
|
||||
.rekyc-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 16px 24px;
|
||||
max-width: 280px;
|
||||
background: #fff;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
&.has-file {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-zone-icon {
|
||||
font-size: 28px !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.upload-zone-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.upload-zone-hint {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.upload-zone-check {
|
||||
font-size: 24px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.upload-zone-filename {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
max-width: 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Result Banners ─── */
|
||||
.rekyc-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
|
||||
.rekyc-result-icon { color: #059669; font-size: 20px; }
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
|
||||
.rekyc-result-icon {
|
||||
color: #dc2626;
|
||||
font-size: 20px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.rekyc-upload-zone { max-width: 100%; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
@@ -51,6 +52,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
clientId: string;
|
||||
kycId: string | null = null;
|
||||
kycData: any = null;
|
||||
clientData: any = null;
|
||||
clientDateOfBirth: string | null = null;
|
||||
isNonIndividual = false;
|
||||
loading = true;
|
||||
@@ -58,6 +60,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
isEditMode = false;
|
||||
errorMessage: string | null = null;
|
||||
|
||||
/** Re-KYC flow state */
|
||||
reKycUploading = false;
|
||||
reKycCompleting = false;
|
||||
reKycSuccess = false;
|
||||
reKycError: string | null = null;
|
||||
selectedDeclarationFile: File | null = null;
|
||||
|
||||
verifying: { pan: boolean; aadhaar: boolean; gstin: boolean } = {
|
||||
pan: false,
|
||||
aadhaar: false,
|
||||
@@ -154,6 +163,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
/** Load client's date of birth and legal form from parent route resolved data */
|
||||
loadClientDateOfBirth(): void {
|
||||
this.route.parent.data.subscribe((data: any) => {
|
||||
this.clientData = data?.clientViewData || null;
|
||||
if (data?.clientViewData?.dateOfBirth) {
|
||||
const dob = data.clientViewData.dateOfBirth;
|
||||
if (Array.isArray(dob) && dob.length >= 3) {
|
||||
@@ -444,6 +454,231 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate Self Declaration PDF with KYC details */
|
||||
printSelfDeclaration(): void {
|
||||
const client = this.clientData || {};
|
||||
const kyc = this.kycData || {};
|
||||
const clientName = client.displayName || client.fullname || '';
|
||||
const cifNumber = client.accountNo || client.cifNumber || this.clientId;
|
||||
const dob = this.clientDateOfBirth ? this.formatDate(client.dateOfBirth) : '—';
|
||||
const officeName = client.officeName || '';
|
||||
const today = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 20;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
let y = 20;
|
||||
|
||||
// Helper
|
||||
const addLine = (label: string, value: string, yPos: number): number => {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
doc.text(label, margin, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(value || '—', margin + 55, yPos);
|
||||
return yPos + 7;
|
||||
};
|
||||
|
||||
const drawHr = (yPos: number): number => {
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(margin, yPos, pageWidth - margin, yPos);
|
||||
return yPos + 5;
|
||||
};
|
||||
|
||||
// ── Header ──
|
||||
doc.setFillColor(15, 23, 42);
|
||||
doc.rect(0, 0, pageWidth, 30, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(16);
|
||||
doc.text('Self Declaration Form', pageWidth / 2, 13, { align: 'center' });
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('KYC / Re-KYC Compliance — As per RBI Master Direction on KYC', pageWidth / 2, 21, { align: 'center' });
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
y = 40;
|
||||
|
||||
// ── Branch / Date ──
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Branch: ${officeName}`, margin, y);
|
||||
doc.text(`Date: ${today}`, pageWidth - margin, y, { align: 'right' });
|
||||
y += 8;
|
||||
y = drawHr(y);
|
||||
|
||||
// ── Section: Customer Details ──
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(16, 116, 185);
|
||||
doc.text('Customer Details', margin, y);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
y += 8;
|
||||
|
||||
y = addLine('Customer Name:', clientName, y);
|
||||
y = addLine('CIF Number:', cifNumber, y);
|
||||
y = addLine('Date of Birth:', dob, y);
|
||||
if (client.mobileNo) y = addLine('Mobile:', client.mobileNo, y);
|
||||
if (client.emailAddress) y = addLine('Email:', client.emailAddress, y);
|
||||
y += 3;
|
||||
y = drawHr(y);
|
||||
|
||||
// ── Section: KYC Documents ──
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(16, 116, 185);
|
||||
doc.text('KYC Document Details', margin, y);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
y += 8;
|
||||
|
||||
// PAN
|
||||
y = addLine('PAN Number:', kyc.panNumber || '—', y);
|
||||
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
|
||||
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
|
||||
|
||||
// Aadhaar
|
||||
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
|
||||
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
|
||||
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
|
||||
|
||||
// PAN-Aadhaar Link
|
||||
const seedingStatus = kyc.panAadhaarSeedingStatus === 'Y' ? 'Linked' : kyc.panAadhaarSeedingStatus === 'N' ? 'Not Linked' : kyc.panAadhaarSeedingStatus === 'E' ? 'Exempt' : 'Pending';
|
||||
y = addLine('PAN-Aadhaar Link:', seedingStatus, y);
|
||||
|
||||
// GSTIN (Non-Individual)
|
||||
if (this.isNonIndividual && kyc.gstinNumber) {
|
||||
y = addLine('GSTIN:', kyc.gstinNumber, y);
|
||||
y = addLine('GSTIN Status:', kyc.gstinVerified ? 'Verified' : 'Pending', y);
|
||||
}
|
||||
|
||||
// Enterprise PAN / CIN
|
||||
if (this.isNonIndividual && kyc.enterprisePan) {
|
||||
y = addLine('Enterprise PAN:', kyc.enterprisePan, y);
|
||||
}
|
||||
if (this.isNonIndividual && kyc.cinNumber) {
|
||||
y = addLine('CIN:', kyc.cinNumber, y);
|
||||
}
|
||||
|
||||
y += 3;
|
||||
y = drawHr(y);
|
||||
|
||||
// ── Section: KYC Summary ──
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(16, 116, 185);
|
||||
doc.text('KYC Summary', margin, y);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
y += 8;
|
||||
|
||||
y = addLine('KYC Type:', kyc.kycType === 1 ? 'Individual' : kyc.kycType === 2 ? 'Non-Individual' : '—', y);
|
||||
y = addLine('Risk Category:', kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—', y);
|
||||
y = addLine('Initiated On:', this.formatDate(kyc.initiatedOn), y);
|
||||
y = addLine('Last Verified:', this.formatDate(kyc.verifiedOn), y);
|
||||
y = addLine('Expiry Date:', this.formatDate(kyc.expiryDate), y);
|
||||
|
||||
y += 3;
|
||||
y = drawHr(y);
|
||||
|
||||
// ── Declaration Text ──
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(16, 116, 185);
|
||||
doc.text('Declaration', margin, y);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
y += 8;
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9.5);
|
||||
const declarationText = `I, ${clientName || '_______________'}, hereby declare that the information provided above is true, correct and complete to the best of my knowledge and belief. I understand that furnishing false information is punishable under applicable laws. I authorize the Bank to verify the above details through appropriate channels as per RBI Master Direction on KYC (DBR.AML.BC.No.81/14.01.001/2015-16) and its subsequent amendments.
|
||||
|
||||
I further confirm that:
|
||||
1. I am not a Politically Exposed Person (PEP) / relative of a PEP (unless declared otherwise).
|
||||
2. The source of funds in my account is from legitimate sources.
|
||||
3. I shall promptly notify the Bank of any change in the above information.
|
||||
4. I have read and understood the Bank's terms and conditions regarding KYC compliance.`;
|
||||
|
||||
const lines = doc.splitTextToSize(declarationText, contentWidth);
|
||||
doc.text(lines, margin, y);
|
||||
y += lines.length * 4.5 + 10;
|
||||
|
||||
// ── Signature section ──
|
||||
y = drawHr(y);
|
||||
y += 5;
|
||||
|
||||
// Left: Client signature
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.text('________________________', margin, y + 15);
|
||||
doc.text('Signature of Customer', margin, y + 21);
|
||||
doc.text(`Name: ${clientName}`, margin, y + 27);
|
||||
doc.text(`Date: _______________`, margin, y + 33);
|
||||
doc.text(`Place: _______________`, margin, y + 39);
|
||||
|
||||
// Right: Bank official
|
||||
doc.text('________________________', pageWidth - margin - 50, y + 15);
|
||||
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
|
||||
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
|
||||
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
|
||||
doc.text('Stamp & Seal', pageWidth - margin - 50, y + 39);
|
||||
|
||||
// ── Footer ──
|
||||
const footerY = doc.internal.pageSize.getHeight() - 10;
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, { align: 'center' });
|
||||
|
||||
// Save
|
||||
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
|
||||
}
|
||||
|
||||
/** Handle file selection for signed declaration upload */
|
||||
onDeclarationFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
this.selectedDeclarationFile = input.files[0];
|
||||
this.reKycError = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload signed declaration and complete re-KYC */
|
||||
completeReKyc(): void {
|
||||
if (!this.selectedDeclarationFile || !this.clientId || !this.kycId) return;
|
||||
|
||||
this.reKycCompleting = true;
|
||||
this.reKycError = null;
|
||||
this.reKycSuccess = false;
|
||||
|
||||
// Step 1: Upload the signed declaration as a client document
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedDeclarationFile);
|
||||
formData.append('name', 'KYC Self Declaration');
|
||||
formData.append('description', `Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`);
|
||||
|
||||
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||
next: () => {
|
||||
// Step 2: Call re-KYC completion API to update verified/expiry dates
|
||||
this.clientsService.completeReKyc(this.clientId, this.kycId!).subscribe({
|
||||
next: () => {
|
||||
this.reKycCompleting = false;
|
||||
this.reKycSuccess = true;
|
||||
this.selectedDeclarationFile = null;
|
||||
this.loadKycData(); // Refresh to show updated dates
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.reKycCompleting = false;
|
||||
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to complete re-KYC. Please try again.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.reKycCompleting = false;
|
||||
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to upload declaration. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -16,7 +16,7 @@ $primary-light: #e8f0fe;
|
||||
&__left { display: flex; align-items: center; gap: 10px; }
|
||||
&__icon { color: #60a5fa; }
|
||||
&__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; }
|
||||
&__sub { font-size: 10px; color: rgba(255,255,255,0.45); margin: 1px 0 0; }
|
||||
&__sub { font-size: 10px; color: rgb(255 255 255 / 45%); margin: 1px 0 0; }
|
||||
}
|
||||
|
||||
.nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b;
|
||||
@@ -36,12 +36,14 @@ $primary-light: #e8f0fe;
|
||||
&__right { display: flex; align-items: center; gap: 10px; }
|
||||
}
|
||||
.nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.nom-type-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
|
||||
border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff;
|
||||
font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s;
|
||||
mat-icon { font-size: 16px; width: 16px; height: 16px; }
|
||||
&__count { font-size: 9px; font-weight: 700; background: #f1f5f9; color: #64748b; padding: 0 5px; border-radius: 6px; }
|
||||
|
||||
&.active { border-color: $primary; background: $primary-light; color: $primary;
|
||||
.nom-type-chip__count { background: $primary; color: #fff; }
|
||||
}
|
||||
@@ -56,6 +58,7 @@ $primary-light: #e8f0fe;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
|
||||
&__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; }
|
||||
&__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
|
||||
|
||||
&__fill {
|
||||
height: 100%; border-radius: 3px; transition: width 0.3s;
|
||||
background: linear-gradient(90deg, #3b82f6, #10b981);
|
||||
@@ -67,12 +70,14 @@ $primary-light: #e8f0fe;
|
||||
/* Nominee card — compact */
|
||||
.nom-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 6px; overflow: hidden; }
|
||||
.nom-card__main { display: flex; align-items: center; gap: 10px; padding: 8px 12px; }
|
||||
|
||||
.nom-card__avatar {
|
||||
width: 32px; height: 32px; border-radius: 50%; background: #dbeafe;
|
||||
display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
|
||||
.nom-card__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
@@ -80,6 +85,7 @@ $primary-light: #e8f0fe;
|
||||
.nom-card__info { flex: 1; min-width: 0; }
|
||||
.nom-card__name { font-weight: 600; font-size: 13px; color: #1e293b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nom-card__meta { font-size: 11px; color: #64748b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nom-card__id {
|
||||
font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff;
|
||||
padding: 2px 8px; border-radius: 4px; white-space: nowrap;
|
||||
@@ -87,12 +93,15 @@ $primary-light: #e8f0fe;
|
||||
}
|
||||
.nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; }
|
||||
.nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; }
|
||||
|
||||
.nom-card__actions { display: flex; gap: 0; flex-shrink: 0;
|
||||
|
||||
button { width: 32px !important; height: 32px !important; line-height: 32px !important;
|
||||
mat-icon { font-size: 18px; }
|
||||
}
|
||||
}
|
||||
.nom-card__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
|
||||
|
||||
.nom-card__guardian {
|
||||
display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed;
|
||||
font-size: 11px; color: #92400e; border-top: 1px solid #fde68a;
|
||||
@@ -122,13 +131,15 @@ $primary-light: #e8f0fe;
|
||||
.nom-search-wrapper { position: relative; margin-bottom: 8px; }
|
||||
.nom-search-field { width: 100%; }
|
||||
.nom-search-prefix { color: #94a3b8; }
|
||||
|
||||
.nom-search-results {
|
||||
position: absolute; top: 56px; left: 0; right: 0; z-index: 100;
|
||||
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.1); max-height: 220px; overflow-y: auto;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 10%); max-height: 220px; overflow-y: auto;
|
||||
&--inline { position: relative; top: 0; margin-top: 4px; }
|
||||
}
|
||||
.nom-search-loading { display: flex; align-items: center; gap: 6px; padding: 10px 12px; font-size: 12px; color: #64748b; }
|
||||
|
||||
.nom-search-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f1f5f9;
|
||||
&:last-child { border-bottom: none; } &:hover { background: #f0f7ff; }
|
||||
@@ -149,6 +160,7 @@ $primary-light: #e8f0fe;
|
||||
&__info { flex: 1; display: flex; flex-direction: column; }
|
||||
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
&__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; }
|
||||
|
||||
&__sig {
|
||||
flex-shrink: 0;
|
||||
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
|
||||
@@ -174,10 +186,11 @@ $primary-light: #e8f0fe;
|
||||
/* Guardian */
|
||||
.nom-guardian-section { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.nom-guardian-label { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #92400e; mat-icon { font-size: 16px; } }
|
||||
|
||||
.nom-guardian-selected { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: #1e293b;
|
||||
button { width: 24px !important; height: 24px !important; line-height: 24px !important; mat-icon { font-size: 14px; } }
|
||||
}
|
||||
|
||||
/* Zoom */
|
||||
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } }
|
||||
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgb(0 0 0 / 15%); } }
|
||||
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
|
||||
|
||||
@@ -83,15 +83,15 @@ $teal: #00897b;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@media (width <= 1024px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (width <= 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ $teal: #00897b;
|
||||
}
|
||||
|
||||
&.monospace {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
@@ -9,28 +9,14 @@
|
||||
<mat-card class="container">
|
||||
<!-- Header Bar -->
|
||||
<div class="table-header">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
{{ 'labels.menus.Clients' | translate }}
|
||||
</h2>
|
||||
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
|
||||
(keydown.enter)="search($event.target.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@if (existsClientsToFilter) {
|
||||
<mat-checkbox #showClosedAccounts labelPosition="after" class="table-checkbox">
|
||||
{{ 'labels.inputs.Show Closed Accounts' | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
<div class="table-header-row">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
{{ 'labels.menus.Clients' | translate }}
|
||||
</h2>
|
||||
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
|
||||
</div>
|
||||
|
||||
<div class="table-buttons">
|
||||
<button
|
||||
@@ -54,6 +40,42 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-filters">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
|
||||
(keydown.enter)="search($event.target.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
<mat-option value="pending">Pending</mat-option>
|
||||
<mat-option value="closed">Closed</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="legalFormFilter" (selectionChange)="applyClientFilters()" placeholder="Legal Form">
|
||||
<mat-option value="">All Forms</mat-option>
|
||||
<mat-option value="1">Individual</mat-option>
|
||||
<mat-option value="2">Non-Individual</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="ckycFilter" (selectionChange)="applyClientFilters()" placeholder="CKYC">
|
||||
<mat-option value="">All CKYC</mat-option>
|
||||
<mat-option value="done">CKYC Done</mat-option>
|
||||
<mat-option value="pending">CKYC Pending</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@@ -69,6 +91,9 @@
|
||||
<td mat-cell *matCellDef="let row" class="cell-link">
|
||||
<mat-icon class="row-legal-icon" [ngClass]="row.legalForm?.id === 2 ? 'entity' : 'person'">{{ row.legalForm?.id === 2 ? 'business' : 'person' }}</mat-icon>
|
||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||
@if (row._ckycDone) {
|
||||
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -86,6 +111,18 @@
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.cifNumber }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CKYC Number Column -->
|
||||
<ng-container matColumnDef="ckycNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>CKYC Number</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
||||
@if (row._ckycNumber) {
|
||||
{{ row._ckycNumber }}
|
||||
} @else {
|
||||
<span class="ckyc-pending">—</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Legal Form Column -->
|
||||
<ng-container matColumnDef="legalForm">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Legal Form' | translate }}</th>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
/** Angular Imports. */
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort, Sort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
@@ -46,7 +45,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./clients.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox,
|
||||
FaIconComponent,
|
||||
MatProgressBar,
|
||||
MatTable,
|
||||
@@ -88,12 +86,11 @@ export class ClientsComponent implements OnInit {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@ViewChild('showClosedAccounts') showClosedAccounts: MatCheckbox;
|
||||
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'accountNumber',
|
||||
'cifNumber',
|
||||
'ckycNumber',
|
||||
'legalForm',
|
||||
'status',
|
||||
'profileCompletion',
|
||||
@@ -115,6 +112,12 @@ export class ClientsComponent implements OnInit {
|
||||
sortAttribute = '';
|
||||
sortDirection = '';
|
||||
|
||||
statusFilter = '';
|
||||
legalFormFilter = '';
|
||||
ckycFilter = '';
|
||||
|
||||
private allRows: any[] = [];
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
@@ -139,14 +142,16 @@ export class ClientsComponent implements OnInit {
|
||||
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
|
||||
.subscribe(
|
||||
(data: any) => {
|
||||
this.dataSource.data = data.content;
|
||||
this.allRows = data.content || [];
|
||||
this.totalRows = data.totalElements;
|
||||
this.existsClientsToFilter = data.numberOfElements > 0;
|
||||
this.notExistsClientsToFilter = !this.existsClientsToFilter;
|
||||
this.isLoading = false;
|
||||
|
||||
this.applyClientFilters();
|
||||
|
||||
// Fetch KYC & address status for visible rows
|
||||
this.loadProfileStatus(data.content);
|
||||
this.loadProfileStatus(this.allRows);
|
||||
},
|
||||
(error: any) => {
|
||||
this.isLoading = false;
|
||||
@@ -167,7 +172,7 @@ export class ClientsComponent implements OnInit {
|
||||
).subscribe((data: any) => {
|
||||
if (data?.legalForm) {
|
||||
clients[i].legalForm = data.legalForm;
|
||||
this.dataSource.data = [...clients];
|
||||
this.applyClientFilters();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,7 +181,9 @@ export class ClientsComponent implements OnInit {
|
||||
).subscribe((kyc: any) => {
|
||||
if (kyc) {
|
||||
clients[i]._kycDone = kyc.kycStatus === 300;
|
||||
this.dataSource.data = [...clients];
|
||||
clients[i]._ckycDone = !!kyc.ckycNumber;
|
||||
clients[i]._ckycNumber = kyc.ckycNumber || '';
|
||||
this.applyClientFilters();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -185,12 +192,38 @@ export class ClientsComponent implements OnInit {
|
||||
).subscribe((addr: any) => {
|
||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||
clients[i]._addressDone = true;
|
||||
this.dataSource.data = [...clients];
|
||||
this.applyClientFilters();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyClientFilters() {
|
||||
const status = this.statusFilter;
|
||||
const legal = this.legalFormFilter;
|
||||
const ckyc = this.ckycFilter;
|
||||
|
||||
const filtered = (this.allRows || []).filter((row) => {
|
||||
const code = (row?.status?.code || '').toLowerCase();
|
||||
|
||||
if (status && !code.includes(status)) {
|
||||
return false;
|
||||
}
|
||||
if (legal && String(row?.legalForm?.id ?? '') !== legal) {
|
||||
return false;
|
||||
}
|
||||
if (ckyc === 'done' && !row._ckycDone) {
|
||||
return false;
|
||||
}
|
||||
if (ckyc === 'pending' && row._ckycDone) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.dataSource.data = filtered;
|
||||
}
|
||||
|
||||
pageChanged(event: PageEvent) {
|
||||
this.pageSize = event.pageSize;
|
||||
this.currentPage = event.pageIndex;
|
||||
|
||||
@@ -320,6 +320,80 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/identifiers`);
|
||||
}
|
||||
|
||||
getKycProofTypes(type?: 'id' | 'address'): Observable<any> {
|
||||
const options = type ? { params: new HttpParams().set('type', type) } : {};
|
||||
return this.http.get(`/kyc/proof-types`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 – Record Master: fetch clients missing CKYC registration
|
||||
* or artefacts. Backed by GET /ckyc/record-master.
|
||||
*/
|
||||
getCkycRecordMaster(filters: {
|
||||
officeId?: number | string;
|
||||
productId?: number | string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
missing?: 'photo' | 'signature' | 'id' | 'address' | 'any';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return this.http.get(`/ckyc/record-master`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 – Auto ZIP Creation: bundle selected clients into a CERSAI
|
||||
* ZIP on the server. Backed by POST /ckyc/batches.
|
||||
*/
|
||||
createCkycBatch(body: { clientIds: (number | string)[]; remarks?: string }): Observable<any> {
|
||||
return this.http.post(`/ckyc/batches`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2/3 – List CKYC batches. Supports filters and an optional
|
||||
* groupByDate=true to bucket results by created_on for the UI.
|
||||
*/
|
||||
getCkycBatches(filters: {
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
uploadedToCersai?: boolean;
|
||||
groupByDate?: boolean;
|
||||
} = {}): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return this.http.get(`/ckyc/batches`, { params });
|
||||
}
|
||||
|
||||
/** Fetch a single CKYC batch with parsed manifest. */
|
||||
getCkycBatchById(id: number | string): Observable<any> {
|
||||
return this.http.get(`/ckyc/batches/${id}`);
|
||||
}
|
||||
|
||||
/** Download the ZIP attached to a batch (via generic document endpoint). */
|
||||
downloadCkycBatchZip(parentEntityId: number | string, documentId: number | string): Observable<Blob> {
|
||||
return this.http.get(`/ckyc_batch/${parentEntityId}/documents/${documentId}/attachment`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 – SFTP upload to CERSAI. Uploads the batch ZIP over the
|
||||
* SFTP channel and returns the ACK + per-client CKYC numbers.
|
||||
*/
|
||||
uploadCkycBatchToSftp(batchId: number | string): Observable<any> {
|
||||
return this.http.post(`/ckyc/batches/${batchId}/sftp-upload`, {});
|
||||
}
|
||||
|
||||
getClientIdentifierTemplate(clientId: string) {
|
||||
return this.http.get(`/clients/${clientId}/identifiers/template`);
|
||||
}
|
||||
@@ -653,6 +727,29 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
||||
* Sends signatory clientId, DIN number and the non-individual entity name.
|
||||
* Backend saves DIN & verification status in KYC table and returns company list.
|
||||
* @param clientId Signatory's Client ID
|
||||
* @param kycId KYC record ID
|
||||
* @param dinNumber DIN number to verify
|
||||
* @param entityName Non-individual entity name (for company match)
|
||||
*/
|
||||
verifyDIN(clientId: string, kycId: string, dinNumber: string, entityName: string): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-din`, { dinNumber, entityName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes Re-KYC after uploading signed self-declaration form.
|
||||
* Updates Last Verified date to today and sets new Expiry Date (today + 10 years for low risk).
|
||||
* @param clientId Client ID
|
||||
* @param kycId KYC record ID
|
||||
*/
|
||||
completeReKyc(clientId: string, kycId: string): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=complete-rekyc`, {});
|
||||
}
|
||||
|
||||
// ═══ Entity Classification APIs ═══
|
||||
|
||||
getEntityClassifications(clientId: string): Observable<any> {
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
.client-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
@@ -96,11 +96,11 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px 16px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
color: rgb(0 0 0 / 54%);
|
||||
font-size: 14px;
|
||||
|
||||
mat-icon {
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
color: rgb(0 0 0 / 38%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.action-bar-right {
|
||||
@@ -119,7 +119,7 @@
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
/* ─── Read-only tab state ─── */
|
||||
@@ -196,8 +196,9 @@
|
||||
::ng-deep .mdc-linear-progress__bar-inner {
|
||||
border-color: #fff !important;
|
||||
}
|
||||
|
||||
::ng-deep .mdc-linear-progress__buffer-bar {
|
||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||
background-color: rgb(255 255 255 / 30%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user