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:
Aditya
2026-04-16 13:51:11 +05:30
co-authored by Claude Opus 4.6
parent 79e455c4ed
commit d72e969dc3
210 changed files with 14273 additions and 1139 deletions
@@ -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
});
}
});