merging code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nitesh Yadav
2026-04-20 14:26:25 +05:30
co-authored by Claude Sonnet 4.6
322 changed files with 28779 additions and 4976 deletions
@@ -34,12 +34,18 @@
<mat-panel-title class="addr-panel__title">
<i class="fa fa-home addr-panel__type-icon"></i>
<span class="addr-panel__type">{{ address.addressType }}</span>
<span class="addr-panel__status" [class.addr-panel__status--active]="address.isActive" [class.addr-panel__status--inactive]="!address.isActive">
<span
class="addr-panel__status"
[class.addr-panel__status--active]="address.isActive"
[class.addr-panel__status--inactive]="!address.isActive"
>
{{ address.isActive ? 'Active' : 'Inactive' }}
</span>
</mat-panel-title>
<mat-panel-description class="addr-panel__desc">
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : '' }}{{ address.countyDistrict || address.addressLine3 || '' }}{{ address.postalCode ? ' - ' + address.postalCode : '' }}
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
}}{{ address.countyDistrict || address.addressLine3 || ''
}}{{ address.postalCode ? ' - ' + address.postalCode : '' }}
</mat-panel-description>
</mat-expansion-panel-header>
@@ -101,12 +107,16 @@
@if (address.stateProvinceId) {
<div class="addr-field">
<div class="addr-field__label">State</div>
<div class="addr-field__value">{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}</div>
<div class="addr-field__value">
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
</div>
</div>
}
<div class="addr-field">
<div class="addr-field__label">Country</div>
<div class="addr-field__value">{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}</div>
<div class="addr-field__value">
{{ getSelectedValue('countryIdOptions', address.countryId)?.name || 'India' }}
</div>
</div>
</div>
</mat-expansion-panel>
@@ -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,100 @@
</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,64 +126,101 @@
<mat-icon class="ckyc-upload-icon">home</mat-icon>
<span>{{ 'labels.inputs.Address Proof Document' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
@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 (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
@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
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -147,64 +232,100 @@
<mat-icon class="ckyc-upload-icon">photo_camera</mat-icon>
<span>{{ 'labels.inputs.Photo Proof Document' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
@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 (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
@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
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
</div>
}
@if (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -216,64 +337,91 @@
<mat-icon class="ckyc-upload-icon">person</mat-icon>
<span>{{ 'labels.inputs.Image of Client' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
@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 (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
@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
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -285,64 +433,100 @@
<mat-icon class="ckyc-upload-icon">draw</mat-icon>
<span>{{ 'labels.inputs.Signature of Client' | translate }}</span>
</div>
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
@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 (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
@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
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -49,35 +49,266 @@
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 rgb(0 0 0 / 10%);
&::before {
content: '';
position: absolute;
top: -60%;
right: -10%;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgb(255 255 255 / 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: rgb(255 255 255 / 20%);
border: 1px solid rgb(255 255 255 / 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: rgb(255 255 255 / 22%);
border: 1px solid rgb(255 255 255 / 40%);
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: rgb(255 255 255 / 20%);
border: 1px solid rgb(255 255 255 / 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 rgb(159 225 203 / 70%);
animation: cersai-pulse 2s infinite;
}
}
@keyframes cersai-pulse {
0% {
box-shadow: 0 0 0 0 rgb(159 225 203 / 70%);
}
70% {
box-shadow: 0 0 0 10px rgb(159 225 203 / 0%);
}
100% {
box-shadow: 0 0 0 0 rgb(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 rgb(26 115 232 / 6%);
}
}
@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 {
@@ -87,31 +318,60 @@
.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 rgb(26 115 232 / 6%) !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 rgb(26 115 232 / 25%);
flex-shrink: 0;
}
.ckyc-upload-hint {
@@ -163,6 +423,12 @@
}
}
@media (width <= 900px) {
.ckyc-upload-grid {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.ckyc-info-grid {
grid-template-columns: 1fr;
@@ -180,3 +446,157 @@
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 rgb(0 0 0 / 6%);
&--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);
});
}
}
@@ -10,29 +10,36 @@
<div class="cv-top">
<!-- Avatar -->
<div class="cv-avatar">
<img class="cv-avatar__img"
[src]="clientImage ? clientImage : 'assets/images/user_placeholder.png'" />
<img class="cv-avatar__img" [src]="clientImage ? clientImage : 'assets/images/user_placeholder.png'" />
<div class="cv-avatar__actions">
<button class="cv-avatar__btn"
<button
class="cv-avatar__btn"
(click)="doAction('Upload Image')"
matTooltip="{{ 'tooltips.Upload Image' | translate }}"
*mifosxHasPermission="'CREATE_CLIENTIMAGE'">
*mifosxHasPermission="'CREATE_CLIENTIMAGE'"
>
<i class="fa fa-upload"></i>
</button>
<button class="cv-avatar__btn"
<button
class="cv-avatar__btn"
(click)="doAction('Capture Image')"
matTooltip="{{ 'tooltips.Capture Image' | translate }}"
*mifosxHasPermission="'CREATE_CLIENTIMAGE'">
*mifosxHasPermission="'CREATE_CLIENTIMAGE'"
>
<i class="fa fa-camera"></i>
</button>
<button class="cv-avatar__btn cv-avatar__btn--danger"
<button
class="cv-avatar__btn cv-avatar__btn--danger"
(click)="doAction('Delete Image')"
matTooltip="{{ 'tooltips.Delete Image' | translate }}"
*mifosxHasPermission="'DELETE_CLIENTIMAGE'">
*mifosxHasPermission="'DELETE_CLIENTIMAGE'"
>
<i class="fa fa-trash"></i>
</button>
</div>
<span class="cv-avatar__sig" (click)="doAction('View Signature')">{{ 'labels.inputs.View Signature' | translate }}</span>
<span class="cv-avatar__sig" (click)="doAction('View Signature')">{{
'labels.inputs.View Signature' | translate
}}</span>
</div>
<!-- Info -->
@@ -40,12 +47,19 @@
<!-- Name + Status + Menu -->
<div class="cv-name-row">
<div class="cv-name">
<i class="fa fa-stop cv-name__dot" [ngClass]="clientViewData.status.code | statusLookup"
matTooltip="{{ 'labels.status.' + clientViewData.status.value | translate }}"></i>
<i
class="fa fa-stop cv-name__dot"
[ngClass]="clientViewData.status.code | statusLookup"
matTooltip="{{ 'labels.status.' + clientViewData.status.value | translate }}"
></i>
<span class="cv-name__text">
<mifosx-entity-name
[entityName]="complianceHideClientData ? maskName(clientViewData.displayName) : clientViewData.displayName"
[display]="'right'" [hideCopy]="complianceHideClientData"></mifosx-entity-name>
[entityName]="
complianceHideClientData ? maskName(clientViewData.displayName) : clientViewData.displayName
"
[display]="'right'"
[hideCopy]="complianceHideClientData"
></mifosx-entity-name>
</span>
<span class="cv-name__status">
{{ 'labels.status.' + clientViewData.status.value | translate }}
@@ -77,8 +91,12 @@
<span class="cv-id__label">{{ 'labels.inputs.External Id' | translate }}</span>
<span class="cv-id__val">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskValue(clientViewData.externalId) : clientViewData.externalId"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[externalId]="
complianceHideClientData ? maskValue(clientViewData.externalId) : clientViewData.externalId
"
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</span>
</div>
}
@@ -118,7 +136,9 @@
<div class="cv-cell__value">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskValue(clientViewData.mobileNo) : clientViewData.mobileNo"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</div>
</div>
}
@@ -127,8 +147,12 @@
<div class="cv-cell__label">{{ 'labels.inputs.Email' | translate }}</div>
<div class="cv-cell__value">
<mifosx-external-identifier
[externalId]="complianceHideClientData ? maskEmail(clientViewData.emailAddress) : clientViewData.emailAddress"
[completed]="true" [hideCopy]="complianceHideClientData"></mifosx-external-identifier>
[externalId]="
complianceHideClientData ? maskEmail(clientViewData.emailAddress) : clientViewData.emailAddress
"
[completed]="true"
[hideCopy]="complianceHideClientData"
></mifosx-external-identifier>
</div>
</div>
}
@@ -148,10 +172,26 @@
<div class="cv-cell">
<div class="cv-cell__label">DIP Status</div>
<div class="cv-cell__value">
<span class="cv-badge"
[class.cv-badge--green]="clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1"
[class.cv-badge--yellow]="clientViewData.dipVerified !== true && clientViewData.dipVerified !== 'true' && clientViewData.dipVerified !== 1">
{{ (clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) ? 'Verified' : 'Pending' }}
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
"
[class.cv-badge--yellow]="
clientViewData.dipVerified !== true &&
clientViewData.dipVerified !== 'true' &&
clientViewData.dipVerified !== 1
"
>
{{
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
? 'Verified'
: 'Pending'
}}
</span>
</div>
</div>
@@ -160,10 +200,26 @@
<div class="cv-cell">
<div class="cv-cell__label">Sanctions Screening</div>
<div class="cv-cell__value">
<span class="cv-badge"
[class.cv-badge--green]="clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1"
[class.cv-badge--red]="clientViewData.nameVerified === false || clientViewData.nameVerified === 'false' || clientViewData.nameVerified === 0">
{{ (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) ? 'Clear' : 'Pending' }}
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
"
[class.cv-badge--red]="
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
"
>
{{
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
? 'Clear'
: 'Pending'
}}
</span>
</div>
</div>
@@ -206,244 +262,299 @@
</div>
</div>
<mat-menu #clientMenu="matMenu">
<button mat-menu-item [routerLink]="['edit']" *mifosxHasPermission="'UPDATE_CLIENT'">
<mat-menu #clientMenu="matMenu">
<button mat-menu-item [routerLink]="['edit']" *mifosxHasPermission="'UPDATE_CLIENT'">
<mat-icon matListIcon>
<fa-icon icon="edit" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Edit' | translate }}</span>
</button>
@if (isActive()) {
<button mat-menu-item [matMenuTriggerFor]="accountApplications">
<mat-icon matListIcon>
<fa-icon icon="money-bill-alt" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Applications' | translate }}</span>
</button>
}
<button mat-menu-item [matMenuTriggerFor]="Actions">
<mat-icon matListIcon>
<fa-icon icon="tasks" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Actions' | translate }}</span>
</button>
@if (!clientViewData.staffId) {
<button mat-menu-item (click)="doAction('Assign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Assign Staff' | translate }}</span>
</button>
}
@if (clientViewData.staffId) {
<span>
<button mat-menu-item *mifosxHasPermission="'UNASSIGNSTAFF_CLIENT'" (click)="doAction('Unassign Staff')">
<mat-icon matListIcon>
<fa-icon icon="edit" size="sm"></fa-icon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Edit' | translate }}</span>
<span>{{ 'labels.buttons.Unassign Staff' | translate }}</span>
</button>
</span>
}
@if (isActive()) {
<button mat-menu-item [matMenuTriggerFor]="accountApplications">
<mat-icon matListIcon>
<fa-icon icon="money-bill-alt" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Applications' | translate }}</span>
</button>
}
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.buttons.More' | translate }}</button>
</mat-menu>
<button mat-menu-item [matMenuTriggerFor]="Actions">
<mat-icon matListIcon>
<fa-icon icon="tasks" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Actions' | translate }}</span>
</button>
<mat-menu #accountApplications="matMenu">
<button mat-menu-item *mifosxHasPermission="'CREATE_LOAN'" [routerLink]="['loans-accounts', 'create']">
{{ 'labels.buttons.New Loan Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SAVINGSACCOUNT'" [routerLink]="['savings-accounts', 'create']">
{{ 'labels.buttons.New Savings Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SHAREACCOUNT'" [routerLink]="['shares-accounts', 'create']">
{{ 'labels.buttons.New Share Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITACCOUNT'"
[routerLink]="['recurring-deposits-accounts', 'create-recurring-deposits-account']"
>
{{ 'labels.buttons.New Recurring Deposit Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_FIXEDDEPOSITACCOUNT'"
[routerLink]="['fixed-deposits-accounts', 'create']"
>
{{ 'labels.buttons.New Fixed Deposits Account' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
{{ 'labels.buttons.New Current Account' | translate }}
</button>
</mat-menu>
@if (!clientViewData.staffId) {
<button mat-menu-item (click)="doAction('Assign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Assign Staff' | translate }}</span>
</button>
}
<mat-menu #Actions="matMenu">
<button mat-menu-item (click)="doAction('Close')">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-menu-item (click)="doAction('Transfer Client')">
{{ 'labels.buttons.Transfer Client' | translate }}
</button>
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Activate')">
{{ 'labels.buttons.Activate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Withdraw')">
{{ 'labels.buttons.Withdraw' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Reject')">{{ 'labels.buttons.Reject' | translate }}</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item *mifosxHasPermission="'DELETE_CLIENT'" (click)="doAction('Delete')">
{{ 'labels.buttons.Delete' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Closed') {
<span
><button mat-menu-item (click)="doAction('Reactivate')">
{{ 'labels.buttons.Reactivate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Rejected') {
<span
><button mat-menu-item (click)="doAction('Undo Rejection')">
{{ 'labels.buttons.Undo Rejection' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Undo Transfer')">
{{ 'labels.buttons.Undo Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Accept Transfer')">
{{ 'labels.buttons.Accept Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Reject Transfer')">
{{ 'labels.buttons.Reject transfer' | translate }}
</button></span
>
}
</mat-menu>
@if (clientViewData.staffId) {
<span>
<button mat-menu-item *mifosxHasPermission="'UNASSIGNSTAFF_CLIENT'" (click)="doAction('Unassign Staff')">
<mat-icon matListIcon>
<fa-icon icon="user-tie" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.buttons.Unassign Staff' | translate }}</span>
</button>
</span>
}
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.buttons.More' | translate }}</button>
</mat-menu>
<mat-menu #accountApplications="matMenu">
<button mat-menu-item *mifosxHasPermission="'CREATE_LOAN'" [routerLink]="['loans-accounts', 'create']">
{{ 'labels.buttons.New Loan Account' | translate }}
</button>
<mat-menu #More="matMenu">
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
<button mat-menu-item (click)="doAction('Survey')" [disabled]="true">
{{ 'labels.buttons.Survey' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Update Default Savings')"
*mifosxHasPermission="'UPDATESAVINGSACCOUNT_CLIENT'"
>
{{ 'labels.buttons.Update Default Savings' | translate }}
</button>
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
{{ 'labels.buttons.Upload Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
{{ 'labels.buttons.Delete Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Client Screen Reports')">
{{ 'labels.buttons.Client Screen Reports' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Create Standing Instructions')"
*mifosxHasPermission="'CREATE_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.Create Standing Instructions' | translate }}
</button>
@if (clientViewData.status.value !== 'Transfer on hold') {
<span>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_SAVINGSACCOUNT'"
[routerLink]="['savings-accounts', 'create']"
(click)="doAction('View Standing Instructions')"
*mifosxHasPermission="'READ_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.New Savings Account' | translate }}
{{ 'labels.buttons.View Standing Instructions' | translate }}
</button>
<button mat-menu-item *mifosxHasPermission="'CREATE_SHAREACCOUNT'" [routerLink]="['shares-accounts', 'create']">
{{ 'labels.buttons.New Share Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_RECURRINGDEPOSITACCOUNT'"
[routerLink]="['recurring-deposits-accounts', 'create-recurring-deposits-account']"
>
{{ 'labels.buttons.New Recurring Deposit Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_FIXEDDEPOSITACCOUNT'"
[routerLink]="['fixed-deposits-accounts', 'create']"
>
{{ 'labels.buttons.New Fixed Deposits Account' | translate }}
</button>
<button
mat-menu-item
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
[routerLink]="['current-accounts', 'create']"
>
{{ 'labels.buttons.New Current Account' | translate }}
</button>
</mat-menu>
<mat-menu #Actions="matMenu">
<button mat-menu-item (click)="doAction('Close')">{{ 'labels.buttons.Close' | translate }}</button>
<button mat-menu-item (click)="doAction('Transfer Client')">
{{ 'labels.buttons.Transfer Client' | translate }}
</button>
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Activate')">
{{ 'labels.buttons.Activate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Withdraw')">
{{ 'labels.buttons.Withdraw' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item (click)="doAction('Reject')">{{ 'labels.buttons.Reject' | translate }}</button></span
>
}
@if (clientViewData.status.value === 'Pending') {
<span
><button mat-menu-item *mifosxHasPermission="'DELETE_CLIENT'" (click)="doAction('Delete')">
{{ 'labels.buttons.Delete' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Closed') {
<span
><button mat-menu-item (click)="doAction('Reactivate')">
{{ 'labels.buttons.Reactivate' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Rejected') {
<span
><button mat-menu-item (click)="doAction('Undo Rejection')">
{{ 'labels.buttons.Undo Rejection' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Undo Transfer')">
{{ 'labels.buttons.Undo Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Accept Transfer')">
{{ 'labels.buttons.Accept Transfer' | translate }}
</button></span
>
}
@if (clientViewData.status.value === 'Transfer in progress') {
<span
><button mat-menu-item (click)="doAction('Reject Transfer')">
{{ 'labels.buttons.Reject transfer' | translate }}
</button></span
>
}
</mat-menu>
<mat-menu #More="matMenu">
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
<button mat-menu-item (click)="doAction('Survey')" [disabled]="true">
{{ 'labels.buttons.Survey' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Update Default Savings')"
*mifosxHasPermission="'UPDATESAVINGSACCOUNT_CLIENT'"
>
{{ 'labels.buttons.Update Default Savings' | translate }}
</button>
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
{{ 'labels.buttons.Upload Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
{{ 'labels.buttons.Delete Signature' | translate }}
</button>
<button mat-menu-item (click)="doAction('Client Screen Reports')">
{{ 'labels.buttons.Client Screen Reports' | translate }}
</button>
<button
mat-menu-item
(click)="doAction('Create Standing Instructions')"
*mifosxHasPermission="'CREATE_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.Create Standing Instructions' | translate }}
</button>
@if (clientViewData.status.value !== 'Transfer on hold') {
<span>
<button
mat-menu-item
(click)="doAction('View Standing Instructions')"
*mifosxHasPermission="'READ_STANDINGINSTRUCTION'"
>
{{ 'labels.buttons.View Standing Instructions' | translate }}
</button>
</span>
}
</mat-menu>
</span>
}
</mat-menu>
<div>
<nav mat-tab-nav-bar class="cv-tabs" [tabPanel]="tabPanel">
<a mat-tab-link [routerLink]="['./general']" routerLinkActive #general="routerLinkActive" [active]="general.isActive">
<a
mat-tab-link
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
<i class="fa fa-tachometer cv-tab-icon"></i> {{ 'labels.inputs.General' | translate }}
</a>
<a mat-tab-link [routerLink]="['./personal-data']" routerLinkActive #personalData="routerLinkActive" [active]="personalData.isActive">
<a
mat-tab-link
[routerLink]="['./personal-data']"
routerLinkActive
#personalData="routerLinkActive"
[active]="personalData.isActive"
>
<i class="fa fa-user cv-tab-icon"></i> {{ 'labels.inputs.Personal Data' | translate }}
</a>
<a mat-tab-link [routerLink]="['./address']" routerLinkActive #address="routerLinkActive" [active]="address.isActive">
<a
mat-tab-link
[routerLink]="['./address']"
routerLinkActive
#address="routerLinkActive"
[active]="address.isActive"
>
<i class="fa fa-map-marker cv-tab-icon"></i> {{ 'labels.inputs.Address' | translate }}
</a>
@if (clientViewData?.legalForm?.id !== 2) {
<a mat-tab-link [routerLink]="['./family-members']" routerLinkActive #familyMembers="routerLinkActive" [active]="familyMembers.isActive">
<a
mat-tab-link
[routerLink]="['./family-members']"
routerLinkActive
#familyMembers="routerLinkActive"
[active]="familyMembers.isActive"
>
<i class="fa fa-users cv-tab-icon"></i> Family Members
</a>
}
<a mat-tab-link [routerLink]="['./nominees']" routerLinkActive #nomineesTab="routerLinkActive" [active]="nomineesTab.isActive">
<a
mat-tab-link
[routerLink]="['./nominees']"
routerLinkActive
#nomineesTab="routerLinkActive"
[active]="nomineesTab.isActive"
>
<i class="fa fa-shield cv-tab-icon"></i> Nominee
</a>
<a mat-tab-link [routerLink]="['./identities']" routerLinkActive #identities="routerLinkActive" [active]="identities.isActive" *mifosxHasPermission="'READ_CLIENTIDENTIFIER'">
<a
mat-tab-link
[routerLink]="['./identities']"
routerLinkActive
#identities="routerLinkActive"
[active]="identities.isActive"
*mifosxHasPermission="'READ_CLIENTIDENTIFIER'"
>
<i class="fa fa-id-card cv-tab-icon"></i> {{ 'labels.inputs.Identities' | translate }}
</a>
<a mat-tab-link [routerLink]="['./kyc']" routerLinkActive #kyc="routerLinkActive" [active]="kyc.isActive">
<i class="fa fa-check-circle cv-tab-icon"></i> {{ 'labels.inputs.KYC' | translate }}
</a>
@if (clientViewData.legalForm?.id !== 1) {
<a mat-tab-link [routerLink]="['./entity-classification']" routerLinkActive #ecTab="routerLinkActive" [active]="ecTab.isActive">
<a
mat-tab-link
[routerLink]="['./entity-classification']"
routerLinkActive
#ecTab="routerLinkActive"
[active]="ecTab.isActive"
>
<i class="fa fa-building cv-tab-icon"></i> Scheme &amp; Sector
</a>
}
<a mat-tab-link [routerLink]="['./ckyc']" routerLinkActive #ckyc="routerLinkActive" [active]="ckyc.isActive">
<i class="fa fa-shield cv-tab-icon"></i> {{ 'labels.inputs.CKYC' | translate }}
</a>
<a mat-tab-link [routerLink]="['./form60']" routerLinkActive #form60="routerLinkActive" [active]="form60.isActive">
<a
mat-tab-link
[routerLink]="['./form60']"
routerLinkActive
#form60="routerLinkActive"
[active]="form60.isActive"
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a mat-tab-link [routerLink]="['./documents']" routerLinkActive #documents="routerLinkActive" [active]="documents.isActive" *mifosxHasPermission="'READ_DOCUMENT'">
<a
mat-tab-link
[routerLink]="['./documents']"
routerLinkActive
#documents="routerLinkActive"
[active]="documents.isActive"
*mifosxHasPermission="'READ_DOCUMENT'"
>
<i class="fa fa-folder-open cv-tab-icon"></i> {{ 'labels.inputs.Documents' | translate }}
</a>
<a mat-tab-link [routerLink]="['./notes']" routerLinkActive #notes="routerLinkActive" [active]="notes.isActive" *mifosxHasPermission="'READ_CLIENTNOTE'">
<a
mat-tab-link
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
*mifosxHasPermission="'READ_CLIENTNOTE'"
>
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
</a>
@for (clientDatatable of clientDatatables; track clientDatatable) {
@@ -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,14 +42,18 @@ $accent: #0ea5e9;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px);
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;
}
/* stylelint-disable-next-line selector-max-universal */
> * {
position: relative;
z-index: 1;
@@ -69,7 +73,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,40 +88,48 @@ $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;
cursor: pointer;
transition: all 0.12s;
i { font-size: 10px; }
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;
&:hover { color: #fff; text-decoration: underline; }
&:hover {
color: #fff;
text-decoration: underline;
}
}
}
// ── Info column ──
.cv-info { flex: 1; min-width: 0; }
.cv-info {
flex: 1;
min-width: 0;
}
// ── Name row ──
.cv-name-row {
@@ -132,7 +144,9 @@ $accent: #0ea5e9;
align-items: center;
gap: 10px;
&__dot { font-size: 10px; }
&__dot {
font-size: 10px;
}
&__text {
font-size: 20px;
@@ -148,23 +162,26 @@ $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 +204,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 +220,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 +237,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,17 +270,30 @@ $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; }
.actions-menu {
color: #fff !important;
}
// ── 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;
@@ -282,7 +312,8 @@ $accent: #0ea5e9;
}
// Fix copy icons on dark background
.cv-top, .cv-grid {
.cv-top,
.cv-grid {
::ng-deep {
mifosx-external-identifier,
mifosx-account-number {
@@ -292,7 +323,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 +342,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 +383,34 @@ $accent: #0ea5e9;
}
// ── Responsive ──
@media (max-width: 1024px) {
.cv-grid { grid-template-columns: repeat(3, 1fr); }
@media (width <= 1024px) {
.cv-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-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 (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) {
.cv-grid { grid-template-columns: 1fr; }
@media (width <= 480px) {
.cv-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,30 @@
<!--
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>
@@ -0,0 +1,53 @@
/**
* 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;
/* stylelint-disable-next-line selector-type-no-unknown */
::ng-deep image-cropper {
display: block;
max-height: 56vh;
}
}
.icd-actions {
padding: 8px 20px 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
@@ -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;
}
}
@@ -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,398 @@
</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 }}
</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>
<!-- 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>
}
}
</mat-form-field>
</div>
<div class="fd-field fd-field--file">
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
</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>
} @else {
<!-- Standard upload form -->
<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>
</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 +438,19 @@
<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"
class="fd-btn fd-btn--submit"
>
<button mat-flat-button color="primary" [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 }}
}
@@ -184,8 +184,753 @@ $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;
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;
span i {
color: $gray-500;
margin: 0 2px;
}
.kyc-status-hint i {
color: $primary;
}
}
.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-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;
}
}
@@ -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,311 @@ 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];
@@ -1,60 +1,144 @@
.ec-view { padding: 16px 0; }
/**
* 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/.
*/
.ec-view {
padding: 16px 0;
}
.ec-loading {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 48px; color: #64748b;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px;
color: #64748b;
}
.ec-header {
display: flex; align-items: center; justify-content: space-between;
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; }
h3 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
fa-icon {
color: #1565c0;
}
}
}
.ec-count {
font-size: 12px; font-weight: 600; color: #059669;
background: #ecfdf5; padding: 3px 10px; border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
padding: 3px 10px;
border-radius: 12px;
}
.ec-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 40px; color: #94a3b8; font-size: 14px;
background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 10px;
mat-icon { font-size: 36px; width: 36px; height: 36px; }
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 10px;
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
}
}
.ec-card {
border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 12px; overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
}
.ec-card__header {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; background: #f8fafc; border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.ec-card__tag {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 12px;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
}
.ec-card__title { font-size: 14px; font-weight: 600; color: #1e293b; flex: 1; }
.ec-card__title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
flex: 1;
}
.ec-card__badge {
font-size: 10px; font-weight: 700; color: #059669;
background: #ecfdf5; padding: 2px 8px; border-radius: 10px; border: 1px solid #a7f3d0;
font-size: 10px;
font-weight: 700;
color: #059669;
background: #ecfdf5;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid #a7f3d0;
}
.ec-card__body { padding: 14px 16px; }
.ec-card__body {
padding: 14px 16px;
}
.ec-card__grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 16px;
}
.ec-card__field { display: flex; flex-direction: column; gap: 2px; }
.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; }
.ec-card__field {
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 768px) {
.ec-card__grid { grid-template-columns: repeat(2, 1fr); }
.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 (width <= 768px) {
.ec-card__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -1,3 +1,11 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -29,19 +37,45 @@ export class EntityClassificationTabComponent implements OnInit {
categories = [
{ key: 'msme', label: 'MSME', tag: 'Udyam / MSME', tagColor: '#0A5C6B', tagBg: '#E6F4F6', icon: 'cogs' },
{ key: 'startup', label: 'Startup India', tag: 'DPIIT', tagColor: '#1A5FAB', tagBg: '#EAF2FB', icon: 'rocket' },
{ key: 'mudra', label: 'Mudra Borrower', tag: 'PMMY', tagColor: '#8A5A00', tagBg: '#FEF3DC', icon: 'hand-holding-usd' },
{
key: 'mudra',
label: 'Mudra Borrower',
tag: 'PMMY',
tagColor: '#8A5A00',
tagBg: '#FEF3DC',
icon: 'hand-holding-usd'
},
{ key: 'eou', label: 'Export Oriented Unit', tag: 'EOU', tagColor: '#1B7A4A', tagBg: '#E8F5EE', icon: 'ship' },
{ key: 'importexport', label: 'Import Export', tag: 'DGFT / IEC', tagColor: '#5B3A8A', tagBg: '#F0EBF8', icon: 'exchange-alt' },
{
key: 'importexport',
label: 'Import Export',
tag: 'DGFT / IEC',
tagColor: '#5B3A8A',
tagBg: '#F0EBF8',
icon: 'exchange-alt'
},
{ key: 'psl', label: 'Priority Sector', tag: 'RBI PSL', tagColor: '#7B1F1F', tagBg: '#FCEAEA', icon: 'bullseye' },
{ key: 'nps', label: 'Non-Priority Sector', tag: 'General', tagColor: '#444441', tagBg: '#F1EFE8', icon: 'building' }
{
key: 'nps',
label: 'Non-Priority Sector',
tag: 'General',
tagColor: '#444441',
tagBg: '#F1EFE8',
icon: 'building'
}
];
ngOnInit() {
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
if (this.clientId) {
this.clientsService.getEntityClassifications(this.clientId).subscribe({
next: (res: any) => { this.data = res; this.loading = false; },
error: () => { this.loading = false; }
next: (res: any) => {
this.data = res;
this.loading = false;
},
error: () => {
this.loading = false;
}
});
} else {
this.loading = false;
@@ -50,19 +84,25 @@ export class EntityClassificationTabComponent implements OnInit {
get enabledCategories(): any[] {
if (!this.data) return [];
return this.categories.filter(c => this.data[c.key]?.enabled);
return this.categories.filter((c) => this.data[c.key]?.enabled);
}
getCategoryData(key: string): any {
return this.data?.[key] || {};
}
getFieldPairs(catData: any): { label: string, value: any }[] {
getFieldPairs(catData: any): { label: string; value: any }[] {
if (!catData) return [];
const skip = ['id', 'clientId', 'enabled', 'locale', 'dateFormat'];
const skip = [
'id',
'clientId',
'enabled',
'locale',
'dateFormat'
];
return Object.keys(catData)
.filter(k => !skip.includes(k) && catData[k] != null && catData[k] !== '' && catData[k] !== false)
.map(k => ({
.filter((k) => !skip.includes(k) && catData[k] != null && catData[k] !== '' && catData[k] !== false)
.map((k) => ({
label: this.formatLabel(k),
value: this.formatValue(catData[k])
}));
@@ -71,7 +111,7 @@ export class EntityClassificationTabComponent implements OnInit {
private formatLabel(key: string): string {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, s => s.toUpperCase())
.replace(/^./, (s) => s.toUpperCase())
.replace(/Id$/, '')
.trim();
}
@@ -7,7 +7,6 @@
-->
<div class="nom-container mat-typography">
<!-- ═══ FAMILY MEMBERS SECTION ═══ -->
<div class="nom-header">
<div class="nom-header__left">
@@ -50,7 +49,12 @@
<button mat-stroked-button color="primary" class="nom-actions__edit" (click)="editFamilyMember(member, i)">
<fa-icon icon="edit" class="m-r-10"></fa-icon> Edit
</button>
<button mat-stroked-button color="warn" class="nom-actions__delete" (click)="deleteMember(member, 'Family Member')">
<button
mat-stroked-button
color="warn"
class="nom-actions__delete"
(click)="deleteMember(member, 'Family Member')"
>
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
</button>
</div>
@@ -91,5 +95,4 @@
</mat-expansion-panel>
}
</mat-accordion>
</div>
@@ -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;
}
@@ -80,12 +80,12 @@ export class FamilyMembersTabComponent {
/** Family members (non-nominee entries) */
get familyMembers(): any[] {
return this.clientFamilyMembers.filter(m => !m.nominationType || m.nominationType === '');
return this.clientFamilyMembers.filter((m) => !m.nominationType || m.nominationType === '');
}
/** Nominees (entries with nominationType set) */
get nominees(): any[] {
return this.clientFamilyMembers.filter(m => m.nominationType && m.nominationType !== '');
return this.clientFamilyMembers.filter((m) => m.nominationType && m.nominationType !== '');
}
/** Get relationship display name */
@@ -44,7 +44,9 @@ $white: #fff;
height: 32px !important;
line-height: 32px !important;
color: $gray-500;
transition: color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
background 0.15s ease;
&:hover {
color: $gray-900;
@@ -176,7 +178,7 @@ $white: #fff;
}
// Responsive
@media (max-width: 600px) {
@media (width <= 600px) {
.fd-grid--cols-2 {
grid-template-columns: 1fr;
}
@@ -48,8 +48,14 @@ export class NomineeDialogComponent implements OnInit {
const member = this.data.member;
this.nomineeForm = this.formBuilder.group({
nominationType: [member?.nominationType || 'DA1'],
firstName: [member?.firstName || '', Validators.required],
relationshipId: [member?.relationshipId || '', Validators.required],
firstName: [
member?.firstName || '',
Validators.required
],
relationshipId: [
member?.relationshipId || '',
Validators.required
],
dateOfBirth: [member?.dateOfBirth ? this.dateUtils.formatDate(member.dateOfBirth, 'yyyy-MM-dd') : null],
percentageShare: [member?.percentageShare ?? 100],
isDependent: [member?.isDependent || false],
@@ -61,13 +61,17 @@
}
@if (form60Data.dateOfBirth) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">calendar_today</mat-icon> Date of Birth</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">calendar_today</mat-icon> Date of Birth</span
>
<span class="f60-detail__value">{{ form60Data.dateOfBirth | dateFormat }}</span>
</div>
}
@if (form60Data.fatherName) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">family_restroom</mat-icon> Father's Name</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">family_restroom</mat-icon> Father's Name</span
>
<span class="f60-detail__value">{{ form60Data.fatherName }}</span>
</div>
}
@@ -79,25 +83,33 @@
}
@if (form60Data.transactionType) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">receipt_long</mat-icon> Transaction Type</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">receipt_long</mat-icon> Transaction Type</span
>
<span class="f60-detail__value">{{ form60Data.transactionType }}</span>
</div>
}
@if (form60Data.transactionAmount) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">currency_rupee</mat-icon> Transaction Amount</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">currency_rupee</mat-icon> Transaction Amount</span
>
<span class="f60-detail__value">&#8377; {{ form60Data.transactionAmount }}</span>
</div>
}
@if (form60Data.estimatedTotalIncome) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">account_balance_wallet</mat-icon> Estimated Total Income</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">account_balance_wallet</mat-icon> Estimated Total Income</span
>
<span class="f60-detail__value">&#8377; {{ form60Data.estimatedTotalIncome }}</span>
</div>
}
@if (form60Data.reasonForNoPan) {
<div class="f60-detail">
<span class="f60-detail__label"><mat-icon class="f60-detail__icon">help_outline</mat-icon> Reason for No PAN</span>
<span class="f60-detail__label"
><mat-icon class="f60-detail__icon">help_outline</mat-icon> Reason for No PAN</span
>
<span class="f60-detail__value">{{ form60Data.reasonForNoPan }}</span>
</div>
}
@@ -181,7 +193,8 @@
<!-- Declaration -->
<div class="f60-declaration">
<mat-checkbox formControlName="declaration">
I hereby declare that I do not have a Permanent Account Number (PAN) and the information provided above is true and correct to the best of my knowledge and belief.
I hereby declare that I do not have a Permanent Account Number (PAN) and the information provided above
is true and correct to the best of my knowledge and belief.
</mat-checkbox>
</div>
@@ -6,19 +6,26 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1E6BB8;
$primary-light: #EBF3FF;
$primary: #1e6bb8;
$primary-light: #ebf3ff;
$green: #16a34a;
$green-light: #dcfce7;
$amber: #f59e0b;
$amber-light: #fef3c7;
$border: #E2E8F0;
$text: #1A2332;
$muted: #64748B;
$bg: #F0F4F8;
$border: #e2e8f0;
$text: #1a2332;
$muted: #64748b;
$bg: #f0f4f8;
.f60-container { padding: 20px 24px; }
.f60-loading { display: flex; justify-content: center; padding: 40px; }
.f60-container {
padding: 20px 24px;
}
.f60-loading {
display: flex;
justify-content: center;
padding: 40px;
}
/* ── PAN OK — Not Required ── */
.f60-pan-ok {
@@ -29,96 +36,260 @@ $bg: #F0F4F8;
border-radius: 12px;
&__icon-wrap {
width: 72px; height: 72px; border-radius: 50%;
background: #fff; display: inline-flex; align-items: center;
justify-content: center; margin-bottom: 16px;
width: 72px;
height: 72px;
border-radius: 50%;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba($green, 0.15);
}
&__icon { font-size: 40px; width: 40px; height: 40px; color: $green; }
&__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; }
&__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;
strong { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.5px; }
font-size: 15px;
color: $text;
margin: 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
strong {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.5px;
}
}
&__tick {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: $green;
}
&__verified {
font-size: 13px;
font-weight: 600;
color: $green;
}
&__rule {
font-size: 12px;
color: $muted;
margin: 12px 0 0;
}
&__tick { font-size: 18px !important; width: 18px !important; height: 18px !important; color: $green; }
&__verified { font-size: 13px; font-weight: 600; color: $green; }
&__rule { font-size: 12px; color: $muted; margin: 12px 0 0; }
}
/* ── Warning Banner ── */
.f60-warning {
display: flex; align-items: center; gap: 12px;
background: $amber-light; padding: 14px 20px;
border-left: 4px solid $amber; border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
background: $amber-light;
padding: 14px 20px;
border-left: 4px solid $amber;
border-radius: 8px;
margin-bottom: 20px;
&__icon { color: $amber; font-size: 24px; flex-shrink: 0; }
&__text { font-size: 13px; color: #92400e; line-height: 1.5; strong { font-weight: 700; } }
&__icon {
color: $amber;
font-size: 24px;
flex-shrink: 0;
}
&__text {
font-size: 13px;
color: #92400e;
line-height: 1.5;
strong {
font-weight: 700;
}
}
}
/* ── Card ── */
.f60-card {
background: #fff; border: 1px solid $border; border-radius: 12px; overflow: hidden;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
&__header {
display: flex; align-items: center; gap: 10px;
padding: 14px 20px; background: $bg; border-bottom: 1px solid $border;
h3 { margin: 0; font-size: 15px; font-weight: 600; color: $text; flex: 1; }
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: $bg;
border-bottom: 1px solid $border;
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text;
flex: 1;
}
}
&__header-icon { color: $primary; font-size: 22px; }
&__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;
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; }
font-size: 12px !important;
height: 30px !important;
border-radius: 6px !important;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
}
}
&__body {
padding: 20px;
}
&__body { padding: 20px; }
}
/* ── 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; }
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.f60-detail {
display: flex; flex-direction: column;
&--full { grid-column: 1 / -1; }
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;
display: flex; align-items: center; gap: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: $muted;
letter-spacing: 0.5px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
}
&__icon {
font-size: 15px !important;
width: 15px !important;
height: 15px !important;
color: $primary;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text;
}
&__icon { font-size: 15px !important; width: 15px !important; height: 15px !important; color: $primary; }
&__value { font-size: 15px; font-weight: 600; color: $text; }
}
/* ── Form Grid ── */
.f60-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.f60-ff {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.f60-ff { width: 100%; &--full { grid-column: 1 / -1; } }
.f60-prefix {
display: inline-flex; align-items: center;
padding-left: 4px; padding-right: 8px; margin-right: 8px;
border-right: 1.5px solid #cbd5e1; line-height: 1;
mat-icon { font-size: 20px; width: 20px; height: 20px; color: $primary; }
display: inline-flex;
align-items: center;
padding-left: 4px;
padding-right: 8px;
margin-right: 8px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
}
/* ── Declaration ── */
.f60-declaration {
margin: 12px 0 20px; padding: 14px 16px;
background: $bg; border-radius: 8px; border: 1px solid $border;
font-size: 13px; line-height: 1.5; color: $muted;
margin: 12px 0 20px;
padding: 14px 16px;
background: $bg;
border-radius: 8px;
border: 1px solid $border;
font-size: 13px;
line-height: 1.5;
color: $muted;
}
/* ── Actions ── */
.f60-actions {
display: flex; justify-content: flex-end; gap: 10px;
button { border-radius: 8px !important; font-weight: 600 !important; }
mat-icon { font-size: 18px; width: 18px; height: 18px; margin-right: 4px; vertical-align: middle; }
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border-radius: 8px !important;
font-weight: 600 !important;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 4px;
vertical-align: middle;
}
}
@@ -90,33 +90,37 @@ export class Form60TabComponent implements OnInit, OnDestroy {
private loadForm60Purposes(): void {
this.loadingPurposes = true;
this.clientsService.getCodeValuesByName('FORM_60_PURPOSE').pipe(
takeUntil(this.destroy$)
).subscribe((values: any[]) => {
this.transactionTypes = (values || [])
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((v: any) => ({ value: v.name, label: v.name }));
this.loadingPurposes = false;
});
this.clientsService
.getCodeValuesByName('FORM_60_PURPOSE')
.pipe(takeUntil(this.destroy$))
.subscribe((values: any[]) => {
this.transactionTypes = (values || [])
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((v: any) => ({ value: v.name, label: v.name }));
this.loadingPurposes = false;
});
}
private checkPanStatus(): void {
this.checkingPan = true;
this.clientsService.getClientKyc(this.clientId).pipe(
takeUntil(this.destroy$),
catchError(() => of(null))
).subscribe((kyc: any) => {
this.checkingPan = false;
if (kyc?.panNumber) {
this.hasPan = true;
this.panNumber = kyc.panNumber;
this.panVerified = kyc.panVerified || false;
this.loading = false;
} else {
this.hasPan = false;
this.loadForm60Data();
}
});
this.clientsService
.getClientKyc(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of(null))
)
.subscribe((kyc: any) => {
this.checkingPan = false;
if (kyc?.panNumber) {
this.hasPan = true;
this.panNumber = kyc.panNumber;
this.panVerified = kyc.panVerified || false;
this.loading = false;
} else {
this.hasPan = false;
this.loadForm60Data();
}
});
}
private createForm(): void {
@@ -7,7 +7,6 @@
-->
<div class="general-tab-container mat-typography">
<!-- Performance History - KPI Stat Cards -->
<section class="performance-section">
<h3 class="performance-title">{{ 'labels.heading.Performance History' | translate }}</h3>
@@ -164,7 +163,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Saving Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Saving Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -211,7 +212,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Saving Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Saving Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -258,7 +261,8 @@
<ng-container matColumnDef="Name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.name' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="!(element.isWaived || element.isPaid) | statusLookup"></i> {{ element.name }}
<i class="fa fa-stop" [ngClass]="!(element.isWaived || element.isPaid) | statusLookup"></i>
{{ element.name }}
</td>
</ng-container>
@@ -322,7 +326,11 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="upcomingChargesColumns"></tr>
<tr mat-row *matRowDef="let row; columns: upcomingChargesColumns" [routerLink]="['../', 'charges', row.id]"></tr>
<tr
mat-row
*matRowDef="let row; columns: upcomingChargesColumns"
[routerLink]="['../', 'charges', row.id]"
></tr>
<tr mat-footer-row *matFooterRowDef="upcomingCharges?.length === 0 ? ['no-data'] : []"></tr>
</table>
</div>
@@ -407,7 +415,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Fixed Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Fixed Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -454,7 +464,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Fixed Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Fixed Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -559,7 +571,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Recurring Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -607,7 +621,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -775,7 +791,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openLoansColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Loan Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Loan Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -867,7 +885,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedLoansColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Loan Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Loan Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -924,7 +944,8 @@
<th mat-header-cell class="r-amount" *matHeaderCellDef>{{ 'labels.inputs.Total Value' | translate }}</th>
<td mat-cell class="r-amount" *matCellDef="let element">
{{
element.basePrice * element.quantity | currency: element.currency?.code || 'USD' : 'symbol-narrow' : '1.2-2'
element.basePrice * element.quantity
| currency: element.currency?.code || 'USD' : 'symbol-narrow' : '1.2-2'
}}
</td>
</ng-container>
@@ -1043,7 +1064,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSharesColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Active Share Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Active Share Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -1094,7 +1117,9 @@
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSharesColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">{{ 'labels.messages.No Closed Share Accounts Available' | translate }}</p>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Share Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
@@ -1123,4 +1148,4 @@
<embed [src]="pdfUrl" type="application/pdf" width="100%" height="600px" />
</div>
</div>
</div>
</div>
@@ -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,16 @@ $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;
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 +110,7 @@ $red: #d93025;
// Color variants
&--blue {
border-left-color: $primary-blue;
.stat-card__icon {
background: $primary-blue-light;
color: $primary-blue;
@@ -114,6 +119,7 @@ $red: #d93025;
&--green {
border-left-color: $green;
.stat-card__icon {
background: $green-light;
color: $green;
@@ -122,6 +128,7 @@ $red: #d93025;
&--purple {
border-left-color: $purple;
.stat-card__icon {
background: $purple-light;
color: $purple;
@@ -130,6 +137,7 @@ $red: #d93025;
&--teal {
border-left-color: $teal;
.stat-card__icon {
background: $teal-light;
color: $teal;
@@ -138,6 +146,7 @@ $red: #d93025;
&--orange {
border-left-color: $orange;
.stat-card__icon {
background: $orange-light;
color: $orange;
@@ -204,7 +213,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;
@@ -214,6 +223,7 @@ $red: #d93025;
}
// ── Mat Table Styling ──
/* stylelint-disable selector-no-qualifying-type */
table[mat-table] {
width: 100%;
border-collapse: collapse;
@@ -307,6 +317,7 @@ table[mat-table] {
font-size: 12px;
}
}
/* stylelint-enable selector-no-qualifying-type */
// Status color classes (for the fa-stop dots)
:host ::ng-deep {
@@ -370,7 +381,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 +395,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 {
@@ -14,7 +14,13 @@
<h3 class="idt-header__title">{{ 'labels.heading.Identities' | translate }}</h3>
<span class="idt-header__badge">{{ clientIdentities?.length || 0 }}</span>
</div>
<button mat-flat-button color="primary" class="idt-header__btn" (click)="addIdentifier()" *mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'">
<button
mat-flat-button
color="primary"
class="idt-header__btn"
(click)="addIdentifier()"
*mifosxHasPermission="'CREATE_CLIENTIDENTIFIER'"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@@ -35,30 +41,38 @@
<div class="idt-card__title">
<i class="fa fa-file-text idt-card__type-icon"></i>
<span class="idt-card__type">{{ identity.documentType?.name }}</span>
<span class="idt-card__status" [class.idt-card__status--active]="identity.status === 'clientIdentifierStatusType.active'" [class.idt-card__status--inactive]="identity.status !== 'clientIdentifierStatusType.active'">
<span
class="idt-card__status"
[class.idt-card__status--active]="identity.status === 'clientIdentifierStatusType.active'"
[class.idt-card__status--inactive]="identity.status !== 'clientIdentifierStatusType.active'"
>
{{ identity.status === 'clientIdentifierStatusType.active' ? 'Active' : 'Inactive' }}
</span>
</div>
<button mat-stroked-button color="warn" class="idt-card__delete" (click)="deleteIdentifier(identity.clientId, identity.id, i)" *mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'">
<button
mat-stroked-button
color="warn"
class="idt-card__delete"
(click)="deleteIdentifier(identity.clientId, identity.id, i)"
*mifosxHasPermission="'DELETE_CLIENTIDENTIFIER'"
>
<i class="fa fa-trash"></i>
</button>
</div>
<!-- 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,25 +82,35 @@
<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)) {
<div class="idt-doc" [class.idt-doc--clickable]="isPreviewable(document)"
<div
class="idt-doc"
[class.idt-doc--clickable]="isPreviewable(document)"
(click)="isPreviewable(document) && openDocumentPreview(identity, document)"
(keydown.enter)="isPreviewable(document) && openDocumentPreview(identity, document)"
tabindex="0" role="button"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name">
tabindex="0"
role="button"
[attr.aria-label]="('labels.buttons.Preview' | translate) + ': ' + document.name"
>
@if (previewThumbnails[document.id]) {
<img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" />
<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)) {
@@ -37,7 +37,10 @@ $red-light: #fce8e6;
gap: 10px;
}
&__icon { font-size: 16px; color: $primary; }
&__icon {
font-size: 16px;
color: $primary;
}
&__title {
margin: 0;
@@ -71,198 +74,318 @@ $red-light: #fce8e6;
border-radius: 10px;
border: 1px dashed $gray-200;
&__icon { font-size: 32px; color: $gray-500; margin-bottom: 8px; }
&__text { color: $gray-500; font-size: 13px; margin: 0; }
&__icon {
font-size: 32px;
color: $gray-500;
margin-bottom: 8px;
}
&__text {
color: $gray-500;
font-size: 13px;
margin: 0;
}
}
// Identity Card
.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 rgb(26 115 232 / 8%);
}
&__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 rgb(26 115 232 / 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 rgb(26 115 232 / 10%);
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,18 +394,36 @@ $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; }
&:hover .idt-doc__preview {
opacity: 1;
}
}
.document-lightbox-host { display: none; }
.document-lightbox-host {
display: none;
}
@@ -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,36 @@ 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 +177,94 @@ 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 +276,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) -->
@@ -58,12 +65,22 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.pan || !kycData.panNumber" (click)="verifyPAN()">
@if (verifying.pan) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Re-Verify }
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
@@ -81,11 +98,18 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked" (click)="startAadhaarReVerify()">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
@@ -106,12 +130,22 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.gstinVerifiedOn ? formatDate(kycData.gstinVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.gstinVerifiedOn ? formatDate(kycData.gstinVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button"
[disabled]="verifying.gstin || !kycData.gstinNumber" (click)="verifyGSTIN()">
@if (verifying.gstin) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Re-Verify }
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.gstin || !kycData.gstinNumber"
(click)="verifyGSTIN()"
>
@if (verifying.gstin) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
@@ -131,7 +165,9 @@
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{ kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—' }}</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
@@ -195,12 +231,27 @@
<div class="kv-otp-bar">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input matInput [(ngModel)]="aadhaarFullNumber" [ngModelOptions]="{standalone: true}" maxlength="12" type="password" />
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar" (click)="sendAadhaarOtp()">
@if (verifying.aadhaar) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Send OTP }
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
</div>
}
@@ -209,32 +260,69 @@
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input matInput [(ngModel)]="aadhaarOtp" [ngModelOptions]="{standalone: true}" maxlength="6" autocomplete="one-time-code" />
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button mat-raised-button color="primary" type="button" class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying" (click)="verifyAadhaarOtp()">
@if (aadhaarOtpVerifying) { <mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> }
@else { Verify }
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
<button mat-stroked-button type="button" class="kv-otp-btn" [disabled]="verifying.aadhaar" (click)="sendAadhaarOtp()">Resend</button>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}</div>
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
</div>
}
@if (verificationError.pan) {
<div class="kyc-error-msg"><mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.pan }}</div>
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.pan }}
</div>
}
<!-- KYC Meta Grid -->
<div class="kv-meta-grid">
<div class="kv-meta-item">
<span class="kv-meta-label">KYC Type</span>
<span class="kv-meta-value">{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}</span>
<span class="kv-meta-value">{{
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
}}</span>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Risk Category</span>
<span class="kv-meta-value">{{ kycData.riskCategory === 1 ? 'Low' : kycData.riskCategory === 2 ? 'Medium' : kycData.riskCategory === 3 ? 'High' : '—' }}</span>
<span class="kv-meta-value">{{
kycData.riskCategory === 1
? 'Low'
: kycData.riskCategory === 2
? 'Medium'
: kycData.riskCategory === 3
? 'High'
: '—'
}}</span>
</div>
<div class="kv-meta-item">
<span class="kv-meta-label">Initiated On</span>
@@ -255,6 +343,105 @@
</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 &amp; 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 &amp; 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 -->
@@ -164,6 +164,24 @@
color: #757575;
border: 1px solid #e0e0e0;
}
&.linked {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&.not-linked {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.exempt {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
}
.status-chip-icon {
@@ -375,26 +393,6 @@
margin-bottom: 16px;
}
.status-chip {
&.linked {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
&.not-linked {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.exempt {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
}
.pan-aadhaar-detail {
margin: 12px 0;
display: flex;
@@ -468,10 +466,17 @@
padding: 0;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #f8fafc; }
&:last-child {
border-bottom: none;
}
&--link { background: #fafbfc; }
&:hover {
background: #f8fafc;
}
&--link {
background: #fafbfc;
}
}
.kv-table__col {
@@ -486,10 +491,25 @@
gap: 8px;
font-weight: 600;
}
&--num { flex: 1; }
&--status { flex: 0 0 150px; }
&--date { flex: 0 0 150px; color: #64748b; font-size: 12px; }
&--action { flex: 0 0 110px; text-align: right; }
&--num {
flex: 1;
}
&--status {
flex: 0 0 150px;
}
&--date {
flex: 0 0 150px;
color: #64748b;
font-size: 12px;
}
&--action {
flex: 0 0 110px;
text-align: right;
}
}
.kv-doc-icon {
@@ -520,10 +540,25 @@
height: 14px !important;
}
&--ok { background: #ecfdf5; color: #059669; }
&--pending { background: #f8fafc; color: #94a3b8; }
&--fail { background: #fef2f2; color: #dc2626; }
&--info { background: #eff6ff; color: #2563eb; }
&--ok {
background: #ecfdf5;
color: #059669;
}
&--pending {
background: #f8fafc;
color: #94a3b8;
}
&--fail {
background: #fef2f2;
color: #dc2626;
}
&--info {
background: #eff6ff;
color: #2563eb;
}
}
.kv-action-btn {
@@ -558,14 +593,21 @@
font-weight: 600;
white-space: nowrap;
mat-icon { font-size: 16px !important; width: 16px !important; height: 16px !important; }
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
}
}
.kv-otp-input {
width: 200px;
flex-shrink: 0;
::ng-deep .mat-mdc-form-field-infix { padding: 6px 0 !important; min-height: 36px !important; }
::ng-deep .mat-mdc-form-field-infix {
padding: 6px 0 !important;
min-height: 36px !important;
}
}
.kv-otp-btn {
@@ -594,8 +636,14 @@
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
&:nth-child(5n) { border-right: none; }
&--full { grid-column: 1 / -1; border-right: none; }
&:nth-child(5n) {
border-right: none;
}
&--full {
grid-column: 1 / -1;
border-right: none;
}
}
.kv-meta-label {
@@ -612,20 +660,332 @@
color: #1e293b;
}
@media (max-width: 960px) {
@media (width <= 960px) {
.kv-table__col {
&--doc { flex: 0 0 120px; }
&--date { display: none; }
&--action { flex: 0 0 90px; }
&--doc {
flex: 0 0 120px;
}
&--date {
display: none;
}
&--action {
flex: 0 0 90px;
}
}
.kv-meta-grid {
grid-template-columns: repeat(3, 1fr);
}
.kv-meta-item:nth-child(5n) {
border-right: 1px solid #f1f5f9;
}
.kv-meta-item:nth-child(3n) {
border-right: none;
}
.kv-meta-grid { grid-template-columns: repeat(3, 1fr); }
.kv-meta-item:nth-child(5n) { border-right: 1px solid #f1f5f9; }
.kv-meta-item:nth-child(3n) { border-right: none; }
}
@media (max-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; }
@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) {
@@ -437,13 +447,264 @@ export class KycTabComponent implements OnInit, OnDestroy {
getPanAadhaarStatusLabel(status: string): string {
switch (status) {
case 'Y': return 'Linked (Seeded)';
case 'N': return 'Not Linked';
case 'E': return 'Exempt';
default: return 'Pending Verification';
case 'Y':
return 'Linked (Seeded)';
case 'N':
return 'Not Linked';
case 'E':
return 'Exempt';
default:
return 'Pending Verification';
}
}
/** 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();
@@ -6,7 +6,6 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nom-container mat-typography">
<!-- Header -->
<div class="nom-page-header">
<div class="nom-page-header__left">
@@ -39,9 +38,12 @@
<div class="nom-type-chips">
@for (group of accountGroups; track group.type) {
@if (group.accounts.length > 0) {
<button type="button" class="nom-type-chip"
<button
type="button"
class="nom-type-chip"
[class.active]="selectedType === group.type"
(click)="selectedType = group.type; onTypeChange()">
(click)="selectedType = group.type; onTypeChange()"
>
<mat-icon>{{ group.icon }}</mat-icon>
{{ group.label }}
<span class="nom-type-chip__count">{{ group.accounts.length }}</span>
@@ -55,13 +57,23 @@
<mat-label>Account</mat-label>
<mat-select [(ngModel)]="selectedAccountId" (ngModelChange)="onAccountChange()">
@for (acc of selectedGroup.accounts; track acc.id) {
<mat-option [value]="acc.id">{{ acc.productName }} — {{ acc.accountNo }}@if (acc.accountBalance != null) { ({{ acc.accountBalance | number:'1.2-2' }}) }</mat-option>
<mat-option [value]="acc.id"
>{{ acc.productName }} — {{ acc.accountNo }}
@if (acc.accountBalance != null) {
({{ acc.accountBalance | number: '1.2-2' }})
}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (selectedAccountId && !showAddForm) {
<button mat-flat-button color="primary" class="nom-add-btn"
[disabled]="totalShare >= 100" (click)="showAddForm = true">
<button
mat-flat-button
color="primary"
class="nom-add-btn"
[disabled]="totalShare >= 100"
(click)="showAddForm = true"
>
<mat-icon>add</mat-icon> Add Nominee
</button>
}
@@ -83,10 +95,12 @@
<div class="nom-share-bar">
<span class="nom-share-bar__label">Share</span>
<div class="nom-share-bar__track">
<div class="nom-share-bar__fill"
<div
class="nom-share-bar__fill"
[style.width.%]="totalShare > 100 ? 100 : totalShare"
[class.over]="totalShare > 100"
[class.complete]="totalShare === 100"></div>
[class.complete]="totalShare === 100"
></div>
</div>
<span class="nom-share-bar__value" [class.over]="totalShare > 100">{{ totalShare }}%</span>
</div>
@@ -97,8 +111,12 @@
<div class="nom-card__main">
<div class="nom-card__avatar">
@if (nominee.profileImageUrl) {
<img [src]="nominee.profileImageUrl" alt="" class="nom-card__photo zoomable"
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')" />
<img
[src]="nominee.profileImageUrl"
alt=""
class="nom-card__photo zoomable"
(click)="zoomImage(nominee.profileImageUrl, 'Nominee Photo')"
/>
} @else {
<mat-icon>person</mat-icon>
}
@@ -107,14 +125,22 @@
<span class="nom-card__name">{{ nominee.nomineeDisplayName || nominee.firstName || '—' }}</span>
<span class="nom-card__meta">
{{ getRelationship(nominee) }}
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) { · DOB: {{ (nominee.nomineeDateOfBirth || nominee.dateOfBirth) | dateFormat }} }
@if (nominee.nomineeMobileNo || nominee.mobileNo) { · {{ nominee.nomineeMobileNo || nominee.mobileNo }} }
@if (nominee.nomineeDateOfBirth || nominee.dateOfBirth) {
· DOB: {{ nominee.nomineeDateOfBirth || nominee.dateOfBirth | dateFormat }}
}
@if (nominee.nomineeMobileNo || nominee.mobileNo) {
· {{ nominee.nomineeMobileNo || nominee.mobileNo }}
}
</span>
</div>
@if (nominee.signatureUrl) {
<div class="nom-card__sig">
<img [src]="nominee.signatureUrl" alt="Signature" class="zoomable"
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')" />
<img
[src]="nominee.signatureUrl"
alt="Signature"
class="zoomable"
(click)="zoomImage(nominee.signatureUrl, 'Nominee Signature')"
/>
</div>
}
@if (nominee.nomineeIdentifier) {
@@ -124,16 +150,30 @@
<span class="nom-card__share-value">{{ nominee.percentageShare || 0 }}%</span>
</div>
<div class="nom-card__actions">
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)"><mat-icon>visibility</mat-icon></button>
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button type="button" matTooltip="Delete" class="nom-card__delete" (click)="deleteNominee(nominee, i)"><mat-icon>delete</mat-icon></button>
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)">
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
matTooltip="Delete"
class="nom-card__delete"
(click)="deleteNominee(nominee, i)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
<div class="nom-card__guardian">
<mat-icon>shield</mat-icon>
Guardian: <strong>{{ nominee.guardianDisplayName || nominee.guardianName }}</strong>
@if (nominee.guardianRelationshipName) { ({{ nominee.guardianRelationshipName }}) }
@if (nominee.guardianRelationshipName) {
({{ nominee.guardianRelationshipName }})
}
</div>
}
<!-- Inline Edit -->
@@ -143,24 +183,39 @@
<mat-form-field appearance="outline" class="nom-edit-field">
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="editNomType">
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
@for (opt of nominationTypeOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-edit-field">
<mat-label>Relationship</mat-label>
<mat-select [(ngModel)]="editRelId">
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
@for (rel of relationshipOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-edit-field nom-edit-field--sm">
<mat-label>Share %</mat-label>
<input matInput type="number" [(ngModel)]="editShare" min="1" [max]="maxShareForEdit(i)" />
@if (editShare && editShare > maxShareForEdit(i)) { <mat-error>Max {{ maxShareForEdit(i) }}%</mat-error> }
@if (editShare && editShare > maxShareForEdit(i)) {
<mat-error>Max {{ maxShareForEdit(i) }}%</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="button" class="nom-edit-save"
<button
mat-flat-button
color="primary"
type="button"
class="nom-edit-save"
[disabled]="!editRelId || !editShare || editShare > maxShareForEdit(i)"
(click)="saveEdit(nominee, i)"><mat-icon>check</mat-icon></button>
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()"><mat-icon>close</mat-icon></button>
(click)="saveEdit(nominee, i)"
>
<mat-icon>check</mat-icon>
</button>
<button mat-stroked-button type="button" class="nom-edit-cancel" (click)="cancelEdit()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
@@ -185,17 +240,39 @@
<mat-form-field appearance="outline" class="nom-search-field">
<mat-label>Search by Name, Client ID or Mobile</mat-label>
<span matPrefix class="nom-search-prefix"><mat-icon>search</mat-icon></span>
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" (blur)="dismissSearch()" placeholder="Type at least 2 characters..." autocomplete="off" />
<input
matInput
[value]="searchText"
(input)="onSearch($any($event.target).value)"
(blur)="dismissSearch()"
placeholder="Type at least 2 characters..."
autocomplete="off"
/>
</mat-form-field>
@if (searchResults.length > 0 || searching) {
<div class="nom-search-results">
@if (searching) { <div class="nom-search-loading"><mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...</div> }
@if (searching) {
<div class="nom-search-loading">
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> Searching...
</div>
}
@for (client of searchResults; track client.id) {
<div class="nom-search-item" (mousedown)="selectClient(client); $event.preventDefault()">
<mat-icon class="nom-search-item__icon">person</mat-icon>
<div class="nom-search-item__info">
<span class="nom-search-item__name">{{ client.displayName }}</span>
<span class="nom-search-item__meta">ID: {{ client.id }} · {{ client.officeName }}@if (client.dateOfBirth) { · DOB: {{ client.dateOfBirth | dateFormat }} }@if (client.fatherSpouseName) { · {{ client.fatherSpouseName }} }@if (client.mobileNo) { · {{ client.mobileNo }} }</span>
<span class="nom-search-item__meta"
>ID: {{ client.id }} · {{ client.officeName }}
@if (client.dateOfBirth) {
· DOB: {{ client.dateOfBirth | dateFormat }}
}
@if (client.fatherSpouseName) {
· {{ client.fatherSpouseName }}
}
@if (client.mobileNo) {
· {{ client.mobileNo }}
}
</span>
</div>
</div>
}
@@ -212,32 +289,68 @@
<div class="nom-linked-card">
<div class="nom-linked-card__left">
@if (linkedProfileImage) {
<img [src]="linkedProfileImage" class="nom-linked-card__photo zoomable" (click)="zoomImage(linkedProfileImage, 'Photo')" />
} @else { <mat-icon class="nom-linked-card__icon">person</mat-icon> }
<img
[src]="linkedProfileImage"
class="nom-linked-card__photo zoomable"
(click)="zoomImage(linkedProfileImage, 'Photo')"
/>
} @else {
<mat-icon class="nom-linked-card__icon">person</mat-icon>
}
</div>
<div class="nom-linked-card__info">
<span class="nom-linked-card__name">{{ selectedClient.displayName }}</span>
<span class="nom-linked-card__meta">ID: {{ selectedClient.id }}@if (selectedClient.mobileNo) { · {{ selectedClient.mobileNo }} }</span>
<span class="nom-linked-card__meta"
>ID: {{ selectedClient.id }}
@if (selectedClient.mobileNo) {
· {{ selectedClient.mobileNo }}
}
</span>
</div>
@if (linkedSignatureImage) {
<div class="nom-linked-card__sig">
<img [src]="linkedSignatureImage" alt="Signature" class="zoomable" (click)="zoomImage(linkedSignatureImage, 'Signature')" />
<img
[src]="linkedSignatureImage"
alt="Signature"
class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Signature')"
/>
</div>
}
<button mat-icon-button type="button" (click)="removeSelectedClient()"><mat-icon>close</mat-icon></button>
</div>
@if (fetchingDetails) {
<div class="nom-fetching-hint"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...</div>
<div class="nom-fetching-hint">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner> Fetching details...
</div>
}
@if (!fetchingDetails) {
<!-- KYC details -->
@if (selectedClient.dateOfBirth || selectedClient.panNumber || selectedClient.aadhaarMasked) {
<div class="nom-readonly-row">
@if (selectedClient.dateOfBirth) { <span class="nom-ro-item"><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span> }
@if (selectedClient.panNumber) { <span class="nom-ro-item"><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }} @if (linkedKycStatus?.panVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
@if (selectedClient.aadhaarMasked) { <span class="nom-ro-item"><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }} @if (linkedKycStatus?.aadhaarVerified) { <mat-icon class="nom-ro-ok">check_circle</mat-icon> }</span> }
@if (selectedClient.dateOfBirth) {
<span class="nom-ro-item"
><span class="nom-ro-label">DOB</span> {{ selectedClient.dateOfBirth | dateFormat }}</span
>
}
@if (selectedClient.panNumber) {
<span class="nom-ro-item"
><span class="nom-ro-label">PAN</span> {{ selectedClient.panNumber }}
@if (linkedKycStatus?.panVerified) {
<mat-icon class="nom-ro-ok">check_circle</mat-icon>
}
</span>
}
@if (selectedClient.aadhaarMasked) {
<span class="nom-ro-item"
><span class="nom-ro-label">Aadhaar</span> {{ selectedClient.aadhaarMasked }}
@if (linkedKycStatus?.aadhaarVerified) {
<mat-icon class="nom-ro-ok">check_circle</mat-icon>
}
</span>
}
</div>
}
@@ -246,19 +359,25 @@
<mat-form-field appearance="outline" class="nom-field-item">
<mat-label>Nomination Type</mat-label>
<mat-select [(ngModel)]="nomineeNomType">
@for (opt of nominationTypeOptions; track opt.value) { <mat-option [value]="opt.value">{{ opt.label }}</mat-option> }
@for (opt of nominationTypeOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field-item">
<mat-label>Relationship</mat-label>
<mat-select [(ngModel)]="nomineeRelationshipId">
@for (rel of relationshipOptions; track rel) { <mat-option [value]="rel.id">{{ rel.name }}</mat-option> }
@for (rel of relationshipOptions; track rel) {
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="nom-field-item nom-field-item--sm">
<mat-label>Share %</mat-label>
<input matInput type="number" [(ngModel)]="nomineeShare" min="1" [max]="maxAllowedShare" />
@if (nomineeShare && nomineeShare > maxAllowedShare) { <mat-error>Max {{ maxAllowedShare }}%</mat-error> }
@if (nomineeShare && nomineeShare > maxAllowedShare) {
<mat-error>Max {{ maxAllowedShare }}%</mat-error>
}
</mat-form-field>
<div class="nom-field-item nom-field-item--toggle">
<mat-slide-toggle [(ngModel)]="nomineeIsMinor">Minor</mat-slide-toggle>
@@ -270,11 +389,21 @@
<span class="nom-guardian-label"><mat-icon>shield</mat-icon> Guardian</span>
@if (!selectedGuardian) {
<mat-form-field appearance="outline" class="nom-search-field">
<input matInput [ngModel]="guardianSearchText" (ngModelChange)="onGuardianSearch($event)" placeholder="Search guardian..." autocomplete="off" />
<input
matInput
[ngModel]="guardianSearchText"
(ngModelChange)="onGuardianSearch($event)"
placeholder="Search guardian..."
autocomplete="off"
/>
</mat-form-field>
@if (guardianSearchResults.length > 0 || guardianSearching) {
<div class="nom-search-results nom-search-results--inline">
@if (guardianSearching) { <div class="nom-search-loading"><mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner></div> }
@if (guardianSearching) {
<div class="nom-search-loading">
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
</div>
}
@for (client of guardianSearchResults; track client.id) {
<div class="nom-search-item" (click)="selectGuardian(client)">
<mat-icon class="nom-search-item__icon">person</mat-icon>
@@ -284,7 +413,12 @@
</div>
}
} @else {
<span class="nom-guardian-selected">{{ selectedGuardian.displayName }} <button mat-icon-button (click)="selectedGuardian = null"><mat-icon>close</mat-icon></button></span>
<span class="nom-guardian-selected"
>{{ selectedGuardian.displayName }}
<button mat-icon-button (click)="selectedGuardian = null">
<mat-icon>close</mat-icon>
</button></span
>
}
</div>
}
@@ -296,11 +430,24 @@
<div class="nom-form-actions">
<button mat-stroked-button type="button" (click)="resetAddForm()">Cancel</button>
<button mat-raised-button color="primary" type="button"
[disabled]="!nomineeRelationshipId || !nomineeShare || nomineeSaving || (nomineeShare > maxAllowedShare) || (nomineeIsMinor && !selectedGuardian)"
(click)="saveNominee()">
@if (nomineeSaving) { <mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner> }
@else { <mat-icon>check</mat-icon> Save }
<button
mat-raised-button
color="primary"
type="button"
[disabled]="
!nomineeRelationshipId ||
!nomineeShare ||
nomineeSaving ||
nomineeShare > maxAllowedShare ||
(nomineeIsMinor && !selectedGuardian)
"
(click)="saveNominee()"
>
@if (nomineeSaving) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<mat-icon>check</mat-icon> Save
}
</button>
</div>
}
@@ -8,176 +8,708 @@
$primary: #1074b9;
$primary-light: #e8f0fe;
.nom-container { padding: 16px 24px; }
.nom-container {
padding: 16px 24px;
}
/* Header */
.nom-page-header {
background: #0f172a; padding: 14px 20px; border-radius: 10px; margin-bottom: 16px;
&__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; }
background: #0f172a;
padding: 14px 20px;
border-radius: 10px;
margin-bottom: 16px;
&__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: 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;
&--sm { padding: 12px 0; }
.nom-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 24px 0;
justify-content: center;
font-size: 13px;
color: #64748b;
&--sm {
padding: 12px 0;
}
}
.nom-no-accounts {
text-align: center; padding: 36px 20px; background: #f8fafc; border-radius: 8px; border: 1px dashed #e2e8f0;
&__icon { font-size: 40px; width: 40px; height: 40px; color: #94a3b8; margin-bottom: 8px; }
p { color: #64748b; font-size: 13px; margin: 2px 0; }
&__sub { font-size: 11px; color: #94a3b8; }
text-align: center;
padding: 36px 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #94a3b8;
margin-bottom: 8px;
}
p {
color: #64748b;
font-size: 13px;
margin: 2px 0;
}
&__sub {
font-size: 11px;
color: #94a3b8;
}
}
/* Toolbar: chips on top, account + add below */
.nom-toolbar {
display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px;
&__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; }
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
&__right {
display: flex;
align-items: center;
gap: 10px;
}
&:hover:not(.active) { border-color: #94a3b8; }
}
.nom-account-select { flex: 1; min-width: 280px; }
.nom-add-btn { border-radius: 6px !important; font-size: 12px !important; height: 36px !important; white-space: nowrap; }
.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;
}
}
&:hover:not(.active) {
border-color: #94a3b8;
}
}
.nom-account-select {
flex: 1;
min-width: 280px;
}
.nom-add-btn {
border-radius: 6px !important;
font-size: 12px !important;
height: 36px !important;
white-space: nowrap;
}
/* Share bar */
.nom-share-bar {
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
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; }
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
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;
height: 100%;
border-radius: 3px;
transition: width 0.3s;
background: linear-gradient(90deg, #3b82f6, #10b981);
&.over { background: #ef4444; } &.complete { background: linear-gradient(90deg, #10b981, #059669); }
&.over {
background: #ef4444;
}
&.complete {
background: linear-gradient(90deg, #10b981, #059669);
}
}
&__value {
font-size: 13px;
font-weight: 700;
color: #10b981;
min-width: 35px;
text-align: right;
&.over {
color: #ef4444;
}
}
&__value { font-size: 13px; font-weight: 700; color: #10b981; min-width: 35px; text-align: right; &.over { color: #ef4444; } }
}
/* 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 {
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 {
width: 32px;
height: 32px;
border-radius: 50%;
background: #dbeafe;
display: flex;
align-items: center;
justify-content: center;
color: #1565c0;
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 3px; background: #fff; padding: 1px 3px; }
}
.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;
font-family: 'Roboto Mono', monospace; letter-spacing: 0.3px; flex-shrink: 0;
}
.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; }
mat-icon {
font-size: 18px;
}
}
.nom-card__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
.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;
}
}
.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;
font-family: 'Roboto Mono', monospace;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.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;
mat-icon { font-size: 14px; width: 14px; height: 14px; }
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
background: #fff7ed;
font-size: 11px;
color: #92400e;
border-top: 1px solid #fde68a;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
/* Inline edit */
.nom-card__edit { padding: 8px 12px; border-top: 1px solid #e2e8f0; background: #f8fafc; }
.nom-edit-row { display: flex; align-items: center; gap: 8px; }
.nom-edit-field { flex: 1; &--sm { max-width: 100px; } }
.nom-edit-save, .nom-edit-cancel { min-width: 36px !important; height: 36px !important; padding: 0 !important; }
.nom-card__edit {
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.nom-edit-row {
display: flex;
align-items: center;
gap: 8px;
}
.nom-edit-field {
flex: 1;
&--sm {
max-width: 100px;
}
}
.nom-edit-save,
.nom-edit-cancel {
min-width: 36px !important;
height: 36px !important;
padding: 0 !important;
}
/* Empty */
.nom-empty {
text-align: center; padding: 24px; background: #f8fafc; border-radius: 6px; border: 1px dashed #e2e8f0;
mat-icon { font-size: 28px; width: 28px; height: 28px; color: #94a3b8; margin-bottom: 4px; display: block; margin: 0 auto 4px; }
span { font-size: 12px; color: #94a3b8; }
text-align: center;
padding: 24px;
background: #f8fafc;
border-radius: 6px;
border: 1px dashed #e2e8f0;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
display: block;
margin: 0 auto 4px;
}
span {
font-size: 12px;
color: #94a3b8;
}
}
/* ═══ Add Form ═══ */
.nom-add-form {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; margin-top: 8px; background: #fafbfc;
&__title { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 12px; }
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px;
margin-top: 8px;
background: #fafbfc;
&__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
}
/* Search */
.nom-search-wrapper { position: relative; margin-bottom: 8px; }
.nom-search-field { width: 100%; }
.nom-search-prefix { color: #94a3b8; }
.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;
&--inline { position: relative; top: 0; margin-top: 4px; }
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 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-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; }
&__icon { color: $primary; font-size: 18px; }
&__info { display: flex; flex-direction: column; flex: 1; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 10px; color: #64748b; }
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;
}
&__icon {
color: $primary;
font-size: 18px;
}
&__info {
display: flex;
flex-direction: column;
flex: 1;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__meta {
font-size: 10px;
color: #64748b;
}
}
.search-no-results-hint {
font-size: 11px;
color: #94a3b8;
text-align: center;
padding: 4px 0;
}
.search-no-results-hint { font-size: 11px; color: #94a3b8; text-align: center; padding: 4px 0; }
/* Linked card */
.nom-linked-card {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
&__left { flex-shrink: 0; }
&__icon { color: $primary; }
&__photo { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 2px solid #e2e8f0; }
&__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; }
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
&__left {
flex-shrink: 0;
}
&__icon {
color: $primary;
}
&__photo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
&__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; }
img {
height: 24px;
max-width: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 3px;
background: #fff;
padding: 1px 3px;
}
}
}
.nom-fetching-hint { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; margin: -4px 0 8px; }
.nom-fetching-hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #64748b;
margin: -4px 0 8px;
}
/* Read-only KYC row */
.nom-readonly-row {
display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 12px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 10px;
}
.nom-ro-item {
font-size: 12px;
color: #1e293b;
display: inline-flex;
align-items: center;
gap: 4px;
}
.nom-ro-label {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
margin-right: 2px;
}
.nom-ro-ok {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #16a34a;
}
.nom-ro-item { font-size: 12px; color: #1e293b; display: inline-flex; align-items: center; gap: 4px; }
.nom-ro-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
.nom-ro-ok { font-size: 14px !important; width: 14px !important; height: 14px !important; color: #16a34a; }
/* Nominee fields */
.nom-fields-row { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: center; margin-bottom: 4px; }
.nom-field-item { flex: 1; min-width: 160px; &--sm { max-width: 100px; min-width: 80px; } &--toggle { padding-top: 8px; } }
.nom-remarks-field { width: 100%; }
.nom-form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.nom-fields-row {
display: flex;
flex-wrap: wrap;
gap: 0 12px;
align-items: center;
margin-bottom: 4px;
}
.nom-field-item {
flex: 1;
min-width: 160px;
&--sm {
max-width: 100px;
min-width: 80px;
}
&--toggle {
padding-top: 8px;
}
}
.nom-remarks-field {
width: 100%;
}
.nom-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
/* 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; } }
.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); } }
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
.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;
}
}
@@ -109,11 +109,11 @@ export class NomineeTabComponent implements OnInit {
editNomType: string = 'DA1';
get hasAccounts(): boolean {
return this.accountGroups.some(g => g.accounts.length > 0);
return this.accountGroups.some((g) => g.accounts.length > 0);
}
get selectedGroup(): AccountGroup | undefined {
return this.accountGroups.find(g => g.type === this.selectedType);
return this.accountGroups.find((g) => g.type === this.selectedType);
}
get selectedAccount(): any {
@@ -154,13 +154,43 @@ export class NomineeTabComponent implements OnInit {
next: (data: any) => {
this.loading = false;
this.accountGroups = [
{ type: 'savings', apiType: 'SB', label: 'Savings Account', icon: 'savings', accounts: this.filterActive(data.savingsAccounts) },
{ type: 'current', apiType: 'CB', label: 'Current Account', icon: 'account_balance', accounts: this.filterActive(data.currentAccounts) },
{ type: 'fd', apiType: 'FD', label: 'Fixed Deposit', icon: 'lock', accounts: this.filterActive(data.fixedDepositAccounts) },
{ type: 'rd', apiType: 'RD', label: 'Recurring Deposit', icon: 'autorenew', accounts: this.filterActive(data.recurringDepositAccounts) },
{ type: 'loan', apiType: 'LA', label: 'Loan Account', icon: 'credit_score', accounts: this.filterActive(data.loanAccounts) }
{
type: 'savings',
apiType: 'SB',
label: 'Savings Account',
icon: 'savings',
accounts: this.filterActive(data.savingsAccounts)
},
{
type: 'current',
apiType: 'CB',
label: 'Current Account',
icon: 'account_balance',
accounts: this.filterActive(data.currentAccounts)
},
{
type: 'fd',
apiType: 'FD',
label: 'Fixed Deposit',
icon: 'lock',
accounts: this.filterActive(data.fixedDepositAccounts)
},
{
type: 'rd',
apiType: 'RD',
label: 'Recurring Deposit',
icon: 'autorenew',
accounts: this.filterActive(data.recurringDepositAccounts)
},
{
type: 'loan',
apiType: 'LA',
label: 'Loan Account',
icon: 'credit_score',
accounts: this.filterActive(data.loanAccounts)
}
];
const firstWithAccounts = this.accountGroups.find(g => g.accounts.length > 0);
const firstWithAccounts = this.accountGroups.find((g) => g.accounts.length > 0);
if (firstWithAccounts) {
this.selectedType = firstWithAccounts.type;
// Auto-select if only 1 account in the group
@@ -170,18 +200,16 @@ export class NomineeTabComponent implements OnInit {
}
}
},
error: () => { this.loading = false; }
error: () => {
this.loading = false;
}
});
}
private filterActive(accounts: any[]): any[] {
if (!accounts) return [];
// Include all non-closed accounts (active, disbursed, pending, etc.)
return accounts.filter((a: any) =>
a.status?.active ||
a.status?.value === 'Active' ||
!a.status?.closed
);
return accounts.filter((a: any) => a.status?.active || a.status?.value === 'Active' || !a.status?.closed);
}
onTypeChange(): void {
@@ -198,7 +226,9 @@ export class NomineeTabComponent implements OnInit {
onAccountChange(): void {
this.nominees = [];
this.resetAddForm();
if (this.selectedAccountId) { this.loadNominees(); }
if (this.selectedAccountId) {
this.loadNominees();
}
}
private loadNominees(): void {
@@ -210,9 +240,12 @@ export class NomineeTabComponent implements OnInit {
this.nomineesLoading = false;
this.nominees = Array.isArray(data) ? data : [];
// Enrich each nominee with profile image & signature
this.nominees.forEach(n => this.enrichNomineeImages(n));
this.nominees.forEach((n) => this.enrichNomineeImages(n));
},
error: () => { this.nomineesLoading = false; this.nominees = []; }
error: () => {
this.nomineesLoading = false;
this.nominees = [];
}
});
}
@@ -224,7 +257,9 @@ export class NomineeTabComponent implements OnInit {
// Profile image
this.clientsService.getClientProfileImage(cid).subscribe({
next: (img: any) => {
if (img) { nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img); }
if (img) {
nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
},
error: () => {}
});
@@ -232,8 +267,8 @@ export class NomineeTabComponent implements OnInit {
// Signature
this.clientsService.getClientDocuments(cid).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) =>
d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
@@ -253,7 +288,10 @@ export class NomineeTabComponent implements OnInit {
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) { this.searchResults = []; return; }
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
@@ -262,13 +300,19 @@ export class NomineeTabComponent implements OnInit {
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => { this.searchResults = []; this.searching = false; }
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
dismissSearch(): void {
setTimeout(() => { this.searchResults = []; this.searching = false; }, 200);
setTimeout(() => {
this.searchResults = [];
this.searching = false;
}, 200);
}
selectClient(client: any): void {
@@ -291,11 +335,18 @@ export class NomineeTabComponent implements OnInit {
this.linkedSignatureImage = null;
this.linkedKycStatus = null;
let pending = 4;
const done = () => { pending--; if (pending <= 0) { this.fetchingDetails = false; } };
const done = () => {
pending--;
if (pending <= 0) {
this.fetchingDetails = false;
}
};
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) { this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth }; }
if (data?.dateOfBirth) {
this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth };
}
done();
},
error: () => done()
@@ -304,8 +355,15 @@ export class NomineeTabComponent implements OnInit {
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
if (kyc) {
this.selectedClient = { ...this.selectedClient, panNumber: kyc.panNumber, aadhaarMasked: kyc.aadhaarNumberMasked };
this.linkedKycStatus = { panVerified: kyc.panVerified || false, aadhaarVerified: kyc.aadhaarVerified || false };
this.selectedClient = {
...this.selectedClient,
panNumber: kyc.panNumber,
aadhaarMasked: kyc.aadhaarNumberMasked
};
this.linkedKycStatus = {
panVerified: kyc.panVerified || false,
aadhaarVerified: kyc.aadhaarVerified || false
};
}
done();
},
@@ -313,19 +371,31 @@ export class NomineeTabComponent implements OnInit {
});
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => { if (img) { this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img); } done(); },
next: (img: any) => {
if (img) {
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
done();
},
error: () => done()
});
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
next: (docs: any[]) => {
const sigDoc = docs?.find((d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature'));
const sigDoc = docs?.find(
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
next: (blob: Blob) => { this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); done(); },
next: (blob: Blob) => {
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
done();
},
error: () => done()
});
} else { done(); }
} else {
done();
}
},
error: () => done()
});
@@ -336,12 +406,22 @@ export class NomineeTabComponent implements OnInit {
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
clearTimeout(this.guardianSearchTimer);
if (!text || text.length < 2) { this.guardianSearchResults = []; return; }
if (!text || text.length < 2) {
this.guardianSearchResults = [];
return;
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({
next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; this.clientsService.enrichSearchResults(this.guardianSearchResults); },
error: () => { this.guardianSearchResults = []; this.guardianSearching = false; }
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
@@ -385,7 +465,9 @@ export class NomineeTabComponent implements OnInit {
this.loadNominees();
this.resetAddForm();
},
error: () => { this.nomineeSaving = false; }
error: () => {
this.nomineeSaving = false;
}
});
}
@@ -395,26 +477,30 @@ export class NomineeTabComponent implements OnInit {
// Resolve the linked client ID — could be nomineeClientId or nested
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(m => {
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
data: {
context: 'View',
member: {
firstName: displayName,
relationshipId: nominee.relationshipId,
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
linkedClientId: clientId,
linkedClientName: displayName,
isAlive: true,
isDependent: nominee.nomineeIsMinor,
panNumber: nominee.panNumber || '',
aadhaarNumber: nominee.aadhaarMasked || ''
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(
(m) => {
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
data: {
context: 'View',
member: {
firstName: displayName,
relationshipId: nominee.relationshipId,
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
linkedClientId: clientId,
linkedClientName: displayName,
isAlive: true,
isDependent: nominee.nomineeIsMinor,
panNumber: nominee.panNumber || '',
aadhaarNumber: nominee.aadhaarMasked || ''
},
options: { relationshipIdOptions: this.relationshipOptions }
},
options: { relationshipIdOptions: this.relationshipOptions }
},
width: '640px', maxWidth: '95vw', panelClass: 'fm-dialog-panel'
});
});
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
}
);
}
editNominee(nominee: any, index: number): void {
@@ -424,10 +510,12 @@ export class NomineeTabComponent implements OnInit {
this.editNomType = nominee.nominationType || 'DA1';
}
cancelEdit(): void { this.editingIndex = null; }
cancelEdit(): void {
this.editingIndex = null;
}
maxShareForEdit(index: number): number {
return 100 - this.nominees.reduce((sum, n, i) => i === index ? sum : sum + (n.percentageShare || 0), 0);
return 100 - this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
}
saveEdit(nominee: any, index: number): void {
@@ -468,7 +556,8 @@ export class NomineeTabComponent implements OnInit {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw', maxHeight: '90vh'
maxWidth: '90vw',
maxHeight: '90vh'
});
}
@@ -7,7 +7,6 @@
-->
<div class="personal-data-container mat-typography">
<!-- Personal Information -->
<div class="pd-section">
<div class="pd-section__header">
@@ -50,13 +49,15 @@
@if (clientViewData.dateOfBirth) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Date of Birth' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat:'DD-MMMM-YYYY' }}</div>
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat: 'DD-MMMM-YYYY' }}</div>
</div>
}
@if (clientViewData.ageYears != null) {
<div class="pd-field">
<div class="pd-field__label">Age</div>
<div class="pd-field__value">{{ clientViewData.ageYears }}y {{ clientViewData.ageMonths || 0 }}m {{ clientViewData.ageDays || 0 }}d</div>
<div class="pd-field__value">
{{ clientViewData.ageYears }}y {{ clientViewData.ageMonths || 0 }}m {{ clientViewData.ageDays || 0 }}d
</div>
</div>
}
@if (clientViewData.gender) {
@@ -75,7 +76,11 @@
<div class="pd-field">
<div class="pd-field__label">Minor</div>
<div class="pd-field__value">
<span class="pd-verify-badge" [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor">
<span
class="pd-verify-badge"
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
[class.pd-verify-badge--pending]="clientViewData.isMinor"
>
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
</span>
</div>
@@ -84,7 +89,11 @@
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Is staff' | translate }}</div>
<div class="pd-field__value">
<span class="pd-verify-badge" [class.pd-verify-badge--verified]="clientViewData.isStaff" [class.pd-verify-badge--pending]="!clientViewData.isStaff">
<span
class="pd-verify-badge"
[class.pd-verify-badge--verified]="clientViewData.isStaff"
[class.pd-verify-badge--pending]="!clientViewData.isStaff"
>
{{ clientViewData.isStaff ? 'Yes' : 'No' }}
</span>
</div>
@@ -100,7 +109,7 @@
@if (clientViewData.dateOfBirth) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat:'DD-MM-YYYY' }}</div>
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat: 'DD-MM-YYYY' }}</div>
</div>
}
}
@@ -112,7 +121,9 @@
}
<div class="pd-field">
<div class="pd-field__label">Nationality</div>
<div class="pd-field__value">{{ clientViewData.nationalityId || clientViewData.nationality || 'Indian' }}</div>
<div class="pd-field__value">
{{ clientViewData.nationalityId || clientViewData.nationality || 'Indian' }}
</div>
</div>
</div>
</div>
@@ -176,7 +187,9 @@
@if (clientViewData.clientNonPersonDetails.incorpValidityTillDate) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Validity Till Date' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.clientNonPersonDetails.incorpValidityTillDate | dateFormat:'DD-MM-YYYY' }}</div>
<div class="pd-field__value">
{{ clientViewData.clientNonPersonDetails.incorpValidityTillDate | dateFormat: 'DD-MM-YYYY' }}
</div>
</div>
}
@if (clientViewData.clientNonPersonDetails.incorpNumber) {
@@ -221,7 +234,9 @@
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Email' | translate }}</div>
<div class="pd-field__value">
<span class="pd-contact-icon"><i class="fa fa-envelope-o"></i> {{ clientViewData.emailAddress || '-' }}</span>
<span class="pd-contact-icon"
><i class="fa fa-envelope-o"></i> {{ clientViewData.emailAddress || '-' }}</span
>
</div>
</div>
</div>
@@ -240,15 +255,19 @@
<div class="pd-field">
<div class="pd-field__label">Mobile DIP Status</div>
<div class="pd-field__value">
@if (clientViewData.mobileVerified === true || clientViewData.mobileVerified === 'true' || clientViewData.mobileVerified === 1
|| clientViewData.dipVerified === true || clientViewData.dipVerified === 'true' || clientViewData.dipVerified === 1) {
@if (
clientViewData.mobileVerified === true ||
clientViewData.mobileVerified === 'true' ||
clientViewData.mobileVerified === 1 ||
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> Pending
</span>
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
}
</div>
</div>
@@ -270,14 +289,16 @@
<div class="pd-field">
<div class="pd-field__label">Alternate Mobile DIP Status</div>
<div class="pd-field__value">
@if (clientViewData.alternateDipVerified === true || clientViewData.alternateDipVerified === 'true' || clientViewData.alternateDipVerified === 1) {
@if (
clientViewData.alternateDipVerified === true ||
clientViewData.alternateDipVerified === 'true' ||
clientViewData.alternateDipVerified === 1
) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> Pending
</span>
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
}
</div>
</div>
@@ -299,10 +320,20 @@
<div class="pd-field">
<div class="pd-field__label">Name Screening (Sanctions)</div>
<div class="pd-field__value">
@if (clientViewData.nameVerified === true || clientViewData.nameVerified === 'true' || clientViewData.nameVerified === 1) {
@if (
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
) {
<span class="pd-verify-badge pd-verify-badge--verified"><i class="fa fa-check-circle"></i> Clear</span>
} @else if (clientViewData.nameVerified === false || clientViewData.nameVerified === 'false' || clientViewData.nameVerified === 0) {
<span class="pd-verify-badge pd-verify-badge--danger"><i class="fa fa-exclamation-triangle"></i> Match Found</span>
} @else if (
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
) {
<span class="pd-verify-badge pd-verify-badge--danger"
><i class="fa fa-exclamation-triangle"></i> Match Found</span
>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending"><i class="fa fa-clock-o"></i> Pending</span>
}
@@ -320,60 +351,60 @@
<!-- Financial & Classification (Individual only) -->
@if (clientViewData?.legalForm?.id !== 2) {
<div class="pd-section">
<div class="pd-section__header">
<i class="fa fa-chart-line"></i>
<h3>Financial &amp; Classification</h3>
</div>
<div class="pd-section__body">
<div class="pd-grid">
<div class="pd-field">
<div class="pd-field__label">Monthly Income</div>
<div class="pd-field__value">
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
<span class="monospace">&#8377; {{ clientViewData.monthlyIncome | number }}</span>
} @else {
-
}
<div class="pd-section">
<div class="pd-section__header">
<i class="fa fa-chart-line"></i>
<h3>Financial &amp; Classification</h3>
</div>
<div class="pd-section__body">
<div class="pd-grid">
<div class="pd-field">
<div class="pd-field__label">Monthly Income</div>
<div class="pd-field__value">
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
<span class="monospace">&#8377; {{ clientViewData.monthlyIncome | number }}</span>
} @else {
-
}
</div>
</div>
<div class="pd-field">
<div class="pd-field__label">Annual Turnover (Last FY)</div>
<div class="pd-field__value">
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
<span class="monospace">&#8377; {{ clientViewData.annualTurnover | number }}</span>
} @else {
-
}
</div>
</div>
@if (clientViewData.clientType) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Client Type' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.clientType.name }}</div>
</div>
}
@if (clientViewData.clientClassification) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Client Classification' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.clientClassification.name }}</div>
</div>
}
@if (clientViewData.occupationCategory) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Occupation Category' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.occupationCategory.name }}</div>
</div>
}
@if (clientViewData.occupation) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Occupation' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.occupation.name }}</div>
</div>
}
</div>
<div class="pd-field">
<div class="pd-field__label">Annual Turnover (Last FY)</div>
<div class="pd-field__value">
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
<span class="monospace">&#8377; {{ clientViewData.annualTurnover | number }}</span>
} @else {
-
}
</div>
</div>
@if (clientViewData.clientType) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Client Type' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.clientType.name }}</div>
</div>
}
@if (clientViewData.clientClassification) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Client Classification' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.clientClassification.name }}</div>
</div>
}
@if (clientViewData.occupationCategory) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Occupation Category' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.occupationCategory.name }}</div>
</div>
}
@if (clientViewData.occupation) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Occupation' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.occupation.name }}</div>
</div>
}
</div>
</div>
</div>
}
<!-- Guardian Details (Minor) -->
@@ -436,19 +467,19 @@
@if (clientViewData.timeline?.submittedOnDate) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Submitted On' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.timeline.submittedOnDate | dateFormat:'DD-MM-YYYY' }}</div>
<div class="pd-field__value">{{ clientViewData.timeline.submittedOnDate | dateFormat: 'DD-MM-YYYY' }}</div>
</div>
}
@if (clientViewData.activationDate) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Activation Date' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.activationDate | dateFormat:'DD-MM-YYYY' }}</div>
<div class="pd-field__value">{{ clientViewData.activationDate | dateFormat: 'DD-MM-YYYY' }}</div>
</div>
}
@if (clientViewData.timeline?.closedOnDate) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Closed Date' | translate }}</div>
<div class="pd-field__value">{{ clientViewData.timeline.closedOnDate | dateFormat:'DD-MM-YYYY' }}</div>
<div class="pd-field__value">{{ clientViewData.timeline.closedOnDate | dateFormat: 'DD-MM-YYYY' }}</div>
</div>
}
</div>
@@ -467,10 +498,14 @@
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Client Status' | translate }}</div>
<div class="pd-field__value">
<span class="pd-field__value status-badge"
<span
class="pd-field__value status-badge"
[class.status-active]="clientViewData.status.value === 'Active'"
[class.status-closed]="clientViewData.status.value === 'Closed'"
[class.status-pending]="clientViewData.status.value !== 'Active' && clientViewData.status.value !== 'Closed'">
[class.status-pending]="
clientViewData.status.value !== 'Active' && clientViewData.status.value !== 'Closed'
"
>
{{ 'labels.status.' + clientViewData.status.value | translate }}
</span>
</div>
@@ -485,5 +520,4 @@
</div>
</div>
</div>
</div>
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'sass:color';
$primary: #1a73e8;
$primary-light: #e8f0fe;
$gray-50: #f8f9fa;
@@ -83,15 +85,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;
}
}
@@ -165,7 +167,7 @@ $teal: #00897b;
&.status-active {
background: rgba($green, 0.1);
color: darken($green, 10%);
color: color.adjust($green, $lightness: -10%);
&::before {
background: $green;
@@ -183,7 +185,7 @@ $teal: #00897b;
&.status-pending {
background: rgba($orange, 0.1);
color: darken($orange, 15%);
color: color.adjust($orange, $lightness: -15%);
&::before {
background: $orange;
@@ -192,7 +194,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;
}
@@ -230,7 +232,9 @@ $teal: #00897b;
padding: 3px 12px;
border-radius: 12px;
i { font-size: 12px; }
i {
font-size: 12px;
}
&--verified {
background: #ecfdf5;