feat: Client & CKYC UI improvements — filters, KYC management, image crop+merge

- Clients list: CKYC icon, CKYC Number column, status/legal form/CKYC filters
- CKYC: SFTP upload logs, date formatting, modal z-index fix, non-dismissible dialogs
- KYC Management: new standalone module with alert dashboard, notification scheduler, audit inventory, account status
- Image cropper: ngx-image-cropper integration for front+back document merge into single PNG
- Sidebar: KYC Management menu entry after CKYC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-16 13:51:11 +05:30
co-authored by Claude Opus 4.6
parent 79e455c4ed
commit d72e969dc3
210 changed files with 14273 additions and 1139 deletions
+14
View File
@@ -42,6 +42,7 @@
"lightgallery": "^2.9.0", "lightgallery": "^2.9.0",
"lodash": "4.17.23", "lodash": "4.17.23",
"moment": "^2.29.4", "moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2", "ngx-mat-select-search": "^8.0.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"signature_pad": "^5.1.3", "signature_pad": "^5.1.3",
@@ -18822,6 +18823,19 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/ngx-image-cropper": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-9.1.6.tgz",
"integrity": "sha512-b250YJ+jZovfqIj8vdEOrpEFay34be5f1Hpvg6Db68VMlvdyyuzboJdR0gCupbXtVcG6qQ86L7YG+SYxXJwApw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=17.3.0",
"@angular/core": ">=17.3.0"
}
},
"node_modules/ngx-mat-select-search": { "node_modules/ngx-mat-select-search": {
"version": "8.0.4", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/ngx-mat-select-search/-/ngx-mat-select-search-8.0.4.tgz", "resolved": "https://registry.npmjs.org/ngx-mat-select-search/-/ngx-mat-select-search-8.0.4.tgz",
+1
View File
@@ -70,6 +70,7 @@
"lightgallery": "^2.9.0", "lightgallery": "^2.9.0",
"lodash": "4.17.23", "lodash": "4.17.23",
"moment": "^2.29.4", "moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2", "ngx-mat-select-search": "^8.0.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"signature_pad": "^5.1.3", "signature_pad": "^5.1.3",
+4 -4
View File
@@ -59,7 +59,7 @@ $accent: #0ea5e9;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 12px; gap: 12px;
@media (max-width: 768px) { grid-template-columns: 1fr; } @media (width <= 768px) { grid-template-columns: 1fr; }
} }
.acc-card { .acc-card {
@@ -68,14 +68,14 @@ $accent: #0ea5e9;
gap: 14px; gap: 14px;
padding: 16px 18px; padding: 16px 18px;
background: #fff; background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(0 0 0 / 6%);
border-radius: 8px; border-radius: 8px;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s; transition: box-shadow 0.15s, transform 0.15s;
&:hover { &:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -125,7 +125,7 @@ $accent: #0ea5e9;
:host-context(.dark-theme) { :host-context(.dark-theme) {
.acc-card { .acc-card {
background: #1e1e2e; background: #1e1e2e;
border-color: rgba(255, 255, 255, 0.06); border-color: rgb(255 255 255 / 6%);
} }
.acc-card__title { color: #e0e0e0; } .acc-card__title { color: #e0e0e0; }
+2
View File
@@ -51,6 +51,7 @@ import { ConfigurationWizardModule } from './configuration-wizard/configuration-
import { RemittancesModule } from './remittances/remittances.module'; import { RemittancesModule } from './remittances/remittances.module';
import { SupersetAnalyticsModule } from './superset-analytics/superset-analytics.module'; import { SupersetAnalyticsModule } from './superset-analytics/superset-analytics.module';
import { CkycModule } from './ckyc/ckyc.module'; import { CkycModule } from './ckyc/ckyc.module';
import { KycManagementModule } from './kyc-management/kyc-management.module';
import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module'; import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module'; import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
@@ -141,6 +142,7 @@ export function HttpLoaderFactory(http: HttpClient) {
RemittancesModule, RemittancesModule,
SupersetAnalyticsModule, SupersetAnalyticsModule,
CkycModule, CkycModule,
KycManagementModule,
RbiCircularsModule, RbiCircularsModule,
CfcfrmsModule, CfcfrmsModule,
BankCalendarModule, BankCalendarModule,
@@ -434,7 +434,7 @@ $purple: #7c3aed;
} }
// ─── Responsive ─── // ─── Responsive ───
@media screen and (max-width: 1200px) { @media screen and (width <= 1200px) {
.cal-months { .cal-months {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -444,7 +444,7 @@ $purple: #7c3aed;
} }
} }
@media screen and (max-width: 768px) { @media screen and (width <= 768px) {
.cal-months { .cal-months {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -14,11 +14,13 @@
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
} }
.subtitle { .subtitle {
margin: 4px 0 0; margin: 4px 0 0;
color: #666; color: #666;
@@ -73,9 +75,11 @@
.result-card { .result-card {
border-left: 4px solid #ccc; border-left: 4px solid #ccc;
} }
.result-flagged { .result-flagged {
border-left-color: #ef4444; border-left-color: #ef4444;
} }
.result-clear { .result-clear {
border-left-color: #10b981; border-left-color: #10b981;
} }
@@ -83,6 +87,7 @@
.warn-icon { .warn-icon {
color: #ef4444; color: #ef4444;
} }
.clear-icon { .clear-icon {
color: #10b981; color: #10b981;
} }
@@ -100,9 +105,11 @@
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.info-value { .info-value {
font-weight: 600; font-weight: 600;
} }
.ml-24 { .ml-24 {
margin-left: 24px; margin-left: 24px;
} }
@@ -110,9 +117,11 @@
.full-width { .full-width {
width: 100%; width: 100%;
} }
.mr-05 { .mr-05 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mono { .mono {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
@@ -152,6 +161,7 @@
font-size: 13px; font-size: 13px;
color: #7f1d1d; color: #7f1d1d;
border-bottom: 1px solid #fee2e2; border-bottom: 1px solid #fee2e2;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
@@ -162,6 +172,7 @@
text-align: center; text-align: center;
padding: 24px; padding: 24px;
color: #065f46; color: #065f46;
p { p {
margin-top: 12px; margin-top: 12px;
font-size: 15px; font-size: 15px;
@@ -54,7 +54,7 @@
gap: 24px; gap: 24px;
align-items: start; align-items: start;
@media (max-width: 1280px) { @media (width <= 1280px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -58,9 +58,11 @@
&.pending { &.pending {
background: #f59e0b; background: #f59e0b;
} }
&.active { &.active {
background: #1e88e5; background: #1e88e5;
} }
&.resolved { &.resolved {
background: #10b981; background: #10b981;
} }
@@ -35,7 +35,7 @@
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
@media (max-width: 1280px) { @media (width <= 1280px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@@ -80,36 +80,47 @@
&.stat-total { &.stat-total {
border-color: #1e88e5; border-color: #1e88e5;
.stat-icon { .stat-icon {
background: #1e88e5; background: #1e88e5;
} }
.stat-value { .stat-value {
color: #1e88e5; color: #1e88e5;
} }
} }
&.stat-pending { &.stat-pending {
border-color: #f59e0b; border-color: #f59e0b;
.stat-icon { .stat-icon {
background: #f59e0b; background: #f59e0b;
} }
.stat-value { .stat-value {
color: #f59e0b; color: #f59e0b;
} }
} }
&.stat-lien { &.stat-lien {
border-color: #eab308; border-color: #eab308;
.stat-icon { .stat-icon {
background: #eab308; background: #eab308;
} }
.stat-value { .stat-value {
color: #92400e; color: #92400e;
} }
} }
&.stat-recovered { &.stat-recovered {
border-color: #10b981; border-color: #10b981;
.stat-icon { .stat-icon {
background: #10b981; background: #10b981;
} }
.stat-value { .stat-value {
color: #10b981; color: #10b981;
} }
@@ -122,7 +133,7 @@
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
@media (max-width: 1280px) { @media (width <= 1280px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -35,7 +35,7 @@
gap: 24px; gap: 24px;
align-items: start; align-items: start;
@media (max-width: 1280px) { @media (width <= 1280px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -11,70 +11,88 @@
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
} }
.subtitle { .subtitle {
margin: 4px 0 0; margin: 4px 0 0;
color: #666; color: #666;
font-size: 14px; font-size: 14px;
} }
} }
.report-layout { .report-layout {
display: flex; display: flex;
gap: 24px; gap: 24px;
} }
.report-card { .report-card {
flex: 1; flex: 1;
max-width: 700px; max-width: 700px;
} }
.wide-card { .wide-card {
max-width: 900px; max-width: 900px;
} }
.full-width { .full-width {
width: 100%; width: 100%;
} }
.half-width { .half-width {
flex: 1; flex: 1;
} }
.form-row { .form-row {
display: flex; display: flex;
gap: 16px; gap: 16px;
} }
.mr-05 { .mr-05 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mt-16 { .mt-16 {
margin-top: 16px; margin-top: 16px;
} }
.submit-btn { .submit-btn {
margin-top: 8px; margin-top: 8px;
height: 48px; height: 48px;
font-size: 16px; font-size: 16px;
} }
.inline-spinner { .inline-spinner {
display: inline-block; display: inline-block;
margin-right: 8px; margin-right: 8px;
} }
.checkbox-row { .checkbox-row {
margin: 8px 0 16px; margin: 8px 0 16px;
} }
.success-result { .success-result {
text-align: center; text-align: center;
padding: 32px 16px; padding: 32px 16px;
.success-icon { .success-icon {
color: #10b981; color: #10b981;
margin-bottom: 16px; margin-bottom: 16px;
} }
h3 { h3 {
color: #10b981; color: #10b981;
margin-bottom: 24px; margin-bottom: 24px;
} }
} }
.result-details { .result-details {
text-align: left; text-align: left;
max-width: 500px; max-width: 500px;
@@ -83,25 +101,31 @@
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
} }
.result-row { .result-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
} }
.result-label { .result-label {
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.result-value { .result-value {
font-weight: 600; font-weight: 600;
} }
.mono { .mono {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
.status-submitted { .status-submitted {
color: #1e88e5; color: #1e88e5;
font-weight: 700; font-weight: 700;
@@ -14,11 +14,13 @@
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
} }
.subtitle { .subtitle {
margin: 4px 0 0; margin: 4px 0 0;
color: #666; color: #666;
@@ -43,6 +45,7 @@
.mr-05 { .mr-05 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mt-16 { .mt-16 {
margin-top: 16px; margin-top: 16px;
} }
@@ -61,10 +64,12 @@
.success-result { .success-result {
text-align: center; text-align: center;
padding: 32px 16px; padding: 32px 16px;
.success-icon { .success-icon {
color: #10b981; color: #10b981;
margin-bottom: 16px; margin-bottom: 16px;
} }
h3 { h3 {
color: #10b981; color: #10b981;
margin-bottom: 24px; margin-bottom: 24px;
@@ -85,6 +90,7 @@
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
@@ -94,9 +100,11 @@
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.result-value { .result-value {
font-weight: 600; font-weight: 600;
} }
.mono { .mono {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
@@ -14,11 +14,13 @@
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
} }
.subtitle { .subtitle {
margin: 4px 0 0; margin: 4px 0 0;
color: #666; color: #666;
@@ -43,6 +45,7 @@
.mr-05 { .mr-05 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mt-16 { .mt-16 {
margin-top: 16px; margin-top: 16px;
} }
@@ -61,10 +64,12 @@
.success-result { .success-result {
text-align: center; text-align: center;
padding: 32px 16px; padding: 32px 16px;
.success-icon { .success-icon {
color: #10b981; color: #10b981;
margin-bottom: 16px; margin-bottom: 16px;
} }
h3 { h3 {
color: #10b981; color: #10b981;
margin-bottom: 24px; margin-bottom: 24px;
@@ -85,6 +90,7 @@
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
@@ -94,12 +100,15 @@
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.result-value { .result-value {
font-weight: 600; font-weight: 600;
} }
.mono { .mono {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
.status-flagged { .status-flagged {
color: #ef4444; color: #ef4444;
font-weight: 700; font-weight: 700;
@@ -14,11 +14,13 @@
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
} }
.subtitle { .subtitle {
margin: 4px 0 0; margin: 4px 0 0;
color: #666; color: #666;
@@ -43,6 +45,7 @@
.mr-05 { .mr-05 {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mt-16 { .mt-16 {
margin-top: 16px; margin-top: 16px;
} }
@@ -61,10 +64,12 @@
.success-result { .success-result {
text-align: center; text-align: center;
padding: 32px 16px; padding: 32px 16px;
.success-icon { .success-icon {
color: #10b981; color: #10b981;
margin-bottom: 16px; margin-bottom: 16px;
} }
h3 { h3 {
color: #10b981; color: #10b981;
margin-bottom: 24px; margin-bottom: 24px;
@@ -85,6 +90,7 @@
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
@@ -94,9 +100,11 @@
color: #666; color: #666;
font-weight: 500; font-weight: 500;
} }
.result-value { .result-value {
font-weight: 600; font-weight: 600;
} }
.mono { .mono {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
@@ -6,14 +6,565 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="container"> <div class="cv-container">
<mat-card> <!-- Header -->
<mat-card-content> <div class="cv-header">
<div class="placeholder-content"> <div class="cv-header__left">
<fa-icon icon="shield-alt" size="3x" class="placeholder-icon"></fa-icon> <div class="cv-header__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<h3>{{ 'labels.heading.CKYC' | translate }}</h3> <div>
<p>{{ 'labels.text.This section is under development.' | translate }}</p> <h2 class="cv-header__title">CKYC Verification</h2>
<p class="cv-header__sub">Batch · Upload · Match — CERSAI compliance pipeline</p>
</div> </div>
</mat-card-content> </div>
</mat-card> <button class="cv-btn cv-btn--ghost" (click)="fetch()">
<fa-icon icon="sync"></fa-icon>
<span>Refresh</span>
</button>
</div>
<!-- Summary KPIs -->
<div class="cv-kpis">
<div class="cv-kpi">
<div class="cv-kpi__icon cv-kpi__icon--blue"><fa-icon icon="file-archive"></fa-icon></div>
<div>
<div class="cv-kpi__label">Total batches</div>
<div class="cv-kpi__value">{{ totalBatches }}</div>
</div>
</div>
<div class="cv-kpi">
<div class="cv-kpi__icon cv-kpi__icon--teal"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="cv-kpi__label">Total records</div>
<div class="cv-kpi__value">{{ totalRecords }}</div>
</div>
</div>
<div class="cv-kpi">
<div class="cv-kpi__icon cv-kpi__icon--amber"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="cv-kpi__label">Pending upload</div>
<div class="cv-kpi__value">{{ totalPending }}</div>
</div>
</div>
<div class="cv-kpi">
<div class="cv-kpi__icon cv-kpi__icon--green"><fa-icon icon="check-double"></fa-icon></div>
<div>
<div class="cv-kpi__label">Uploaded</div>
<div class="cv-kpi__value">{{ totalUploaded }}</div>
</div>
</div>
<div class="cv-kpi">
<div class="cv-kpi__icon cv-kpi__icon--purple"><fa-icon icon="database"></fa-icon></div>
<div>
<div class="cv-kpi__label">Total size</div>
<div class="cv-kpi__value">{{ formatBytes(totalSize) }}</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cv-filters">
<div class="cv-filters__grid">
<div class="cv-field">
<label class="cv-label"><fa-icon icon="calendar"></fa-icon> From date</label>
<input type="date" class="cv-input" [(ngModel)]="filters.fromDate" />
</div>
<div class="cv-field">
<label class="cv-label"><fa-icon icon="calendar"></fa-icon> To date</label>
<input type="date" class="cv-input" [(ngModel)]="filters.toDate" />
</div>
<div class="cv-field">
<label class="cv-label"><fa-icon icon="filter"></fa-icon> Upload status</label>
<select class="cv-input" [(ngModel)]="filters.uploadedToCersai">
<option value="">All batches</option>
<option value="false">Pending upload</option>
<option value="true">Uploaded</option>
</select>
</div>
</div>
<div class="cv-filters__actions">
<button class="cv-btn cv-btn--ghost" (click)="resetFilters()">
<fa-icon icon="times"></fa-icon> <span>Reset</span>
</button>
<button class="cv-btn cv-btn--primary" (click)="fetch()">
<fa-icon icon="search"></fa-icon> <span>Apply filters</span>
</button>
</div>
</div>
<!-- Section tabs -->
<div class="cv-tabs">
<button
type="button"
class="cv-tab"
[class.cv-tab--active]="activeSection === 'zip'"
(click)="activeSection = 'zip'"
>
<fa-icon icon="file-archive"></fa-icon>
<span>ZIP Batch Details</span>
<span class="cv-tab__badge cv-tab__badge--blue">Phase 2</span>
</button>
<button
type="button"
class="cv-tab"
[class.cv-tab--active]="activeSection === 'sftp'"
(click)="activeSection = 'sftp'"
>
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>SFTP Upload &amp; ACK · API Match</span>
@if (totalPending > 0) {
<span class="cv-tab__count">{{ totalPending }}</span>
}
<span class="cv-tab__badge cv-tab__badge--amber">Phase 3-4</span>
</button>
</div>
<!-- SECTION 1: ZIP Details -->
@if (activeSection === 'zip') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="file-archive"></fa-icon>
<div>
<h3>ZIP Batch Details</h3>
<p>CERSAI ZIP archives grouped by creation date</p>
</div>
</div>
<span class="cv-badge cv-badge--blue">Phase 2</span>
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading batches…</span>
</div>
}
@if (error && !loading) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (!loading && !error && groups.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="file-archive" size="2x"></fa-icon>
<h4>No batches found</h4>
<p>Create a batch from the Record Master to see it here.</p>
</div>
}
@if (!loading && groups.length > 0) {
<div class="cv-groups">
@for (group of groups; track group.dateLabel) {
<div class="cv-group" [class.cv-group--open]="group.expanded">
<button class="cv-group__head" (click)="toggleGroup(group)">
<div class="cv-group__left">
<fa-icon [icon]="group.expanded ? 'chevron-down' : 'chevron-right'" class="cv-group__chev"></fa-icon>
<div class="cv-group__date-pill">
<fa-icon icon="calendar-check"></fa-icon>
<span>{{ group.dateLabel }}</span>
</div>
<div class="cv-group__summary">
<span><strong>{{ group.batchCount }}</strong> batches</span>
<span class="cv-group__sep">·</span>
<span><strong>{{ group.totalRecordCount }}</strong> records</span>
<span class="cv-group__sep">·</span>
<span>{{ formatBytes(group.totalSizeBytes) }}</span>
</div>
</div>
<div class="cv-group__right">
@if (group.pendingCount > 0) {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> {{ group.pendingCount }} pending
</span>
}
@if (group.uploadedCount > 0) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-double"></fa-icon> {{ group.uploadedCount }} uploaded
</span>
}
</div>
</button>
@if (group.expanded) {
<div class="cv-group__body">
<table class="cv-batches">
<thead>
<tr>
<th>Batch ID</th>
<th>Filename</th>
<th class="cv-batches__num">Records</th>
<th class="cv-batches__num">Size</th>
<th>Status</th>
<th class="cv-batches__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (batch of group.batches; track trackBatch($index, batch)) {
<tr>
<td class="cv-batches__bid">
<fa-icon icon="tag"></fa-icon>
<span>{{ batch.batchId }}</span>
</td>
<td class="cv-batches__file">
<fa-icon icon="file-archive"></fa-icon>
<span>{{ batch.filename }}</span>
</td>
<td class="cv-batches__num">{{ batch.recordCount }}</td>
<td class="cv-batches__num">{{ formatBytes(batch.sizeBytes) }}</td>
<td>
@if (batch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</td>
<td class="cv-batches__actions">
<button
class="cv-action-btn"
(click)="openManifest(batch, $event)"
title="View manifest"
>
<fa-icon icon="eye"></fa-icon>
</button>
@if (batch.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(batch, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- SECTION 2: SFTP Upload + API Match -->
@if (activeSection === 'sftp') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<div>
<h3>SFTP Upload &amp; ACK · API Match</h3>
<p>CERSAI SFTP transfer history with ACK receipts and per-batch logs</p>
</div>
</div>
<span class="cv-badge cv-badge--amber">Phase 3-4</span>
</div>
<div class="cv-sftp-kpis">
<div class="cv-sftp-kpi cv-sftp-kpi--green">
<div class="cv-sftp-kpi__icon"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpSuccessCount }}</div>
<div class="cv-sftp-kpi__label">Uploaded successfully</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--red">
<div class="cv-sftp-kpi__icon"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpFailedCount }}</div>
<div class="cv-sftp-kpi__label">Failed</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--amber">
<div class="cv-sftp-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpPendingCount }}</div>
<div class="cv-sftp-kpi__label">Pending upload</div>
</div>
</div>
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading logs…</span>
</div>
}
@if (!loading && sftpLogs.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="cloud-upload-alt" size="2x"></fa-icon>
<h4>No SFTP upload history yet</h4>
<p>Upload a batch from the Record Master to see CERSAI transfer logs here.</p>
</div>
}
@if (!loading && sftpLogs.length > 0) {
<div class="cv-sftp-logs">
@for (log of sftpLogs; track log.id) {
<div
class="cv-sftp-log"
[class.cv-sftp-log--success]="isSftpSuccess(log)"
[class.cv-sftp-log--failed]="isSftpFailed(log)"
>
<div class="cv-sftp-log__icon">
@if (isSftpSuccess(log)) {
<fa-icon icon="check-circle"></fa-icon>
} @else if (isSftpFailed(log)) {
<fa-icon icon="times-circle"></fa-icon>
} @else {
<fa-icon icon="hourglass-half"></fa-icon>
}
</div>
<div class="cv-sftp-log__body">
<div class="cv-sftp-log__head">
<span class="cv-sftp-log__bid">
<fa-icon icon="tag"></fa-icon> {{ log.batchId }}
</span>
@if (isSftpSuccess(log)) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Upload successful
</span>
} @else if (isSftpFailed(log)) {
<span class="cv-pill cv-pill--red">
<fa-icon icon="times-circle"></fa-icon> Upload failed
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
<span class="cv-sftp-log__time">
<fa-icon icon="clock"></fa-icon>
{{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
</span>
</div>
<div class="cv-sftp-log__meta">
<div>
<span class="cv-sftp-log__label">Filename</span>
<span class="cv-sftp-log__value">{{ log.filename }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Records</span>
<span class="cv-sftp-log__value">{{ log.recordCount }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Size</span>
<span class="cv-sftp-log__value">{{ formatBytes(log.sizeBytes) }}</span>
</div>
@if (log.cersaiReference) {
<div>
<span class="cv-sftp-log__label">CERSAI Ref</span>
<span class="cv-sftp-log__value cv-sftp-log__value--mono">{{ log.cersaiReference }}</span>
</div>
}
</div>
@if (log.uploadRemarks) {
<div class="cv-sftp-log__remarks" [class.cv-sftp-log__remarks--err]="isSftpFailed(log)">
<fa-icon [icon]="isSftpFailed(log) ? 'exclamation-triangle' : 'info-circle'"></fa-icon>
<span>{{ log.uploadRemarks }}</span>
</div>
}
</div>
<div class="cv-sftp-log__actions">
<button class="cv-action-btn" (click)="openManifest(log, $event)" title="View manifest">
<fa-icon icon="eye"></fa-icon>
</button>
@if (log.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(log, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</div>
</div>
}
</div>
}
</div>
}
<!-- Manifest modal -->
@if (showManifest) {
<div class="cv-modal" (click)="closeManifest()">
<div class="cv-modal__card" (click)="$event.stopPropagation()">
<div class="cv-modal__header">
<div class="cv-modal__title">
<fa-icon icon="file-archive"></fa-icon>
Batch Manifest
</div>
<button class="cv-modal__close" (click)="closeManifest()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="cv-modal__body">
@if (manifestLoading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading manifest…</span>
</div>
}
@if (manifestError) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ manifestError }}</span>
</div>
}
@if (manifestBatch && !manifestLoading) {
<div class="cv-manifest-head">
<div class="cv-manifest-head__grid">
<div>
<span class="cv-kpi__label">Batch ID</span>
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.batchId }}</span>
</div>
<div>
<span class="cv-kpi__label">Created on</span>
<span class="cv-kpi__value">{{ manifestBatch.createdOnLabel || '—' }}</span>
</div>
<div>
<span class="cv-kpi__label">Records</span>
<span class="cv-kpi__value">{{ manifestBatch.recordCount }}</span>
</div>
<div>
<span class="cv-kpi__label">Size</span>
<span class="cv-kpi__value">{{ formatBytes(manifestBatch.sizeBytes) }}</span>
</div>
<div>
<span class="cv-kpi__label">Status</span>
@if (manifestBatch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</div>
<div class="cv-manifest-head__grid-span">
<span class="cv-kpi__label">Filename</span>
<span class="cv-kpi__value">{{ manifestBatch.filename }}</span>
</div>
<div class="cv-manifest-head__grid-span">
<span class="cv-kpi__label">Checksum</span>
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.checksum }}</span>
</div>
</div>
</div>
@if (manifestBatch.manifest?.length > 0) {
<div class="cv-manifest-title">
<fa-icon icon="list-alt"></fa-icon>
Packaged clients ({{ manifestBatch.manifest.length }})
</div>
<div class="cv-manifest-table-wrap">
<table class="cv-manifest-table">
<thead>
<tr>
<th>Client</th>
<th class="cv-th-c">P</th>
<th class="cv-th-c">S</th>
<th class="cv-th-c">ID</th>
<th class="cv-th-c">Ad</th>
<th class="cv-th-r">Total size</th>
</tr>
</thead>
<tbody>
@for (entry of manifestBatch.manifest; track entry.clientId) {
<tr [class.cv-manifest-row--complete]="isEntryComplete(entry)">
<td>
<div class="cv-manifest-client">
<strong>{{ entry.displayName || 'Client ' + entry.clientId }}</strong>
@if (entry.cifNumber) {
<span class="cv-manifest-cif">
<fa-icon icon="hashtag"></fa-icon>{{ entry.cifNumber }}
</span>
}
</div>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.photo?.packaged"
[class.cv-dot--miss]="!entry.photo?.packaged"
[title]="entry.photo?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.photo?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.signature?.packaged"
[class.cv-dot--miss]="!entry.signature?.packaged"
[title]="entry.signature?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.signature?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.idProof?.packaged"
[class.cv-dot--miss]="!entry.idProof?.packaged"
[title]="(entry.idProof?.type || 'ID') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
>
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.addressProof?.packaged"
[class.cv-dot--miss]="!entry.addressProof?.packaged"
[title]="(entry.addressProof?.type || 'Address') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
>
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-r cv-manifest-size">
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
<div class="cv-modal__footer">
<button class="cv-btn cv-btn--ghost" (click)="closeManifest()">
<fa-icon icon="times"></fa-icon> Close
</button>
@if (manifestBatch?.documentId) {
<button class="cv-btn cv-btn--primary" (click)="downloadZip(manifestBatch, $event)">
<fa-icon icon="download"></fa-icon>
<span>Download ZIP</span>
</button>
}
</div>
</div>
</div>
}
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -6,16 +6,297 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../../clients/clients.service';
interface CkycBatch {
id: number;
batchId: string;
filename: string;
checksum: string;
recordCount: number;
sizeBytes: number;
documentId?: number;
createdOn: any;
uploadedToCersai: boolean;
uploadedOn?: any;
uploadStatus?: string;
uploadRemarks?: string;
cersaiReference?: string;
createdOnLabel?: string;
uploadedOnLabel?: string;
}
interface CkycBatchGroup {
date: any;
dateLabel: string;
batchCount: number;
totalRecordCount: number;
totalSizeBytes: number;
uploadedCount: number;
pendingCount: number;
batches: CkycBatch[];
expanded: boolean;
}
@Component({ @Component({
selector: 'mifosx-ckyc-verification', selector: 'mifosx-ckyc-verification',
templateUrl: './ckyc-verification.component.html', templateUrl: './ckyc-verification.component.html',
styleUrls: ['./ckyc-verification.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent FaIconComponent,
FormsModule
] ]
}) })
export class CkycVerificationComponent {} export class CkycVerificationComponent implements OnInit {
private clientsService = inject(ClientsService);
private destroyRef = inject(DestroyRef);
filters: { fromDate: string; toDate: string; uploadedToCersai: '' | 'true' | 'false' } = {
fromDate: '',
toDate: '',
uploadedToCersai: ''
};
loading = false;
error = '';
activeSection: 'zip' | 'sftp' = 'zip';
groups: CkycBatchGroup[] = [];
totalBatches = 0;
totalRecords = 0;
totalSize = 0;
totalPending = 0;
totalUploaded = 0;
/** SFTP log view */
sftpLogs: CkycBatch[] = [];
sftpSuccessCount = 0;
sftpFailedCount = 0;
sftpPendingCount = 0;
/** Manifest dialog */
showManifest = false;
manifestLoading = false;
manifestError = '';
manifestBatch: any = null;
ngOnInit(): void {
this.fetch();
}
fetch(): void {
this.loading = true;
this.error = '';
const query: any = { groupByDate: true };
if (this.filters.fromDate) query.fromDate = this.filters.fromDate;
if (this.filters.toDate) query.toDate = this.filters.toDate;
if (this.filters.uploadedToCersai !== '') query.uploadedToCersai = this.filters.uploadedToCersai;
this.clientsService
.getCkycBatches(query)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
const rawGroups = res?.groups || [];
this.groups = rawGroups.map((g: any, idx: number) => ({
date: g.date,
dateLabel: this.formatJavaDate(g.date),
batchCount: g.batchCount || 0,
totalRecordCount: g.totalRecordCount || 0,
totalSizeBytes: g.totalSizeBytes || 0,
uploadedCount: g.uploadedCount || 0,
pendingCount: g.pendingCount || 0,
batches: (g.batches || []).map((b: any) => ({
...b,
createdOnLabel: this.formatJavaDate(b.createdOn),
uploadedOnLabel: this.formatJavaDateTime(b.uploadedOn)
})),
expanded: idx === 0
}));
this.computeSummary();
this.computeSftpLogs();
this.loading = false;
},
error: (err: any) => {
this.loading = false;
this.error = err?.error?.defaultUserMessage || err?.message || 'Failed to load CKYC batches.';
this.groups = [];
}
});
}
private computeSummary(): void {
this.totalBatches = 0;
this.totalRecords = 0;
this.totalSize = 0;
this.totalPending = 0;
this.totalUploaded = 0;
for (const g of this.groups) {
this.totalBatches += g.batchCount;
this.totalRecords += g.totalRecordCount;
this.totalSize += g.totalSizeBytes;
this.totalPending += g.pendingCount;
this.totalUploaded += g.uploadedCount;
}
}
resetFilters(): void {
this.filters = { fromDate: '', toDate: '', uploadedToCersai: '' };
this.fetch();
}
toggleGroup(group: CkycBatchGroup): void {
group.expanded = !group.expanded;
}
openManifest(batch: CkycBatch, event: Event): void {
event.stopPropagation();
this.showManifest = true;
this.manifestLoading = true;
this.manifestError = '';
this.manifestBatch = { ...batch, createdOnLabel: this.formatJavaDate(batch.createdOn) };
this.clientsService
.getCkycBatchById(batch.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
this.manifestBatch = {
...res,
createdOnLabel: this.formatJavaDate(res.createdOn),
uploadedOnLabel: this.formatJavaDateTime(res.uploadedOn)
};
this.manifestLoading = false;
},
error: (err: any) => {
this.manifestLoading = false;
this.manifestError = err?.error?.defaultUserMessage || err?.message || 'Failed to load batch details.';
}
});
}
closeManifest(): void {
this.showManifest = false;
this.manifestBatch = null;
this.manifestError = '';
}
downloadZip(batch: CkycBatch, event: Event): void {
event.stopPropagation();
if (!batch.documentId) return;
this.clientsService
.downloadCkycBatchZip(batch.id, batch.documentId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = batch.filename || `batch-${batch.batchId}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: () => undefined
});
}
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 '';
}
formatJavaDateTime(value: any): string {
if (value == null || value === '') return '';
const pad = (n: any) => String(n ?? 0).padStart(2, '0');
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
const date = `${pad(d)}-${pad(mo)}-${y}`;
if (value.length >= 4) {
return `${date} ${pad(h)}:${pad(mi)}:${pad(s)}`;
}
return date;
}
if (typeof value === 'string') {
const dt = new Date(value);
if (!isNaN(dt.getTime())) {
return `${pad(dt.getDate())}-${pad(dt.getMonth() + 1)}-${dt.getFullYear()} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
}
return value;
}
return '';
}
private computeSftpLogs(): void {
const flat: CkycBatch[] = [];
for (const g of this.groups) {
for (const b of g.batches) {
if (b.uploadedToCersai || b.uploadStatus || b.uploadedOn) {
flat.push(b);
}
}
}
flat.sort((a, b) => {
const at = this.dateValue(a.uploadedOn);
const bt = this.dateValue(b.uploadedOn);
return bt - at;
});
this.sftpLogs = flat;
this.sftpSuccessCount = flat.filter((b) => this.isSftpSuccess(b)).length;
this.sftpFailedCount = flat.filter((b) => this.isSftpFailed(b)).length;
this.sftpPendingCount = this.totalPending;
}
isSftpSuccess(b: CkycBatch): boolean {
if (b.uploadStatus) return b.uploadStatus.toLowerCase() === 'success';
return !!b.uploadedToCersai;
}
isSftpFailed(b: CkycBatch): boolean {
return !!b.uploadStatus && b.uploadStatus.toLowerCase() === 'failed';
}
private dateValue(value: any): number {
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
return new Date(y, (mo || 1) - 1, d || 1, h, mi, s).getTime();
}
if (typeof value === 'string') {
const t = Date.parse(value);
return isNaN(t) ? 0 : t;
}
return 0;
}
formatBytes(bytes: number | undefined): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
trackBatch(_: number, b: CkycBatch): number {
return b.id;
}
/** Completion check per manifest entry */
isEntryComplete(entry: any): boolean {
return (
entry?.photo?.packaged &&
entry?.signature?.packaged &&
entry?.idProof?.packaged &&
entry?.addressProof?.packaged
);
}
}
+138 -113
View File
@@ -6,120 +6,145 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="container"> <div class="ckyc-dash">
<mat-card> <!-- Hero -->
<div class="layout-row responsive-column gap-20px"> <div class="ckyc-dash__hero">
<div class="flex-50"> <div class="ckyc-dash__hero-left">
<mat-nav-list> <div class="ckyc-dash__hero-icon">
<mat-list-item> <fa-icon icon="shield-alt"></fa-icon>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['record-master']">
<mat-icon matListIcon>
<fa-icon icon="database" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.Record Master' | translate }}
@if (arrowBooleans[0]) {
<p matLine [routerLink]="['record-master']" class="menu-explanation">
{{ 'labels.text.Manage master records for CKYC processing' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[0]) {
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[0]) {
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['ckyc-verification']">
<mat-icon matListIcon>
<fa-icon icon="shield-alt" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.CKYC' | translate }}
@if (arrowBooleans[1]) {
<p matLine [routerLink]="['ckyc-verification']" class="menu-explanation">
{{ 'labels.text.Central KYC verification and document management' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[1]) {
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[1]) {
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
</mat-nav-list>
</div> </div>
<div>
<div class="flex-50"> <h1 class="ckyc-dash__title">CKYC Compliance Centre</h1>
<mat-nav-list> <p class="ckyc-dash__sub">
<mat-list-item> Central KYC registry management — collect, upload and verify client artefacts against CERSAI
<div class="menu-list-item-content"> </p>
<div class="menu-left-section" [routerLink]="['ckyc-reports']">
<mat-icon matListIcon>
<fa-icon icon="chart-pie" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.CKYC Reports' | translate }}
@if (arrowBooleans[2]) {
<p matLine [routerLink]="['ckyc-reports']" class="menu-explanation">
{{ 'labels.text.View and generate CKYC compliance reports' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[2]) {
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[2]) {
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
<mat-list-item>
<div class="menu-list-item-content">
<div class="menu-left-section" [routerLink]="['ekyc']">
<mat-icon matListIcon>
<fa-icon icon="fingerprint" size="sm"></fa-icon>
</mat-icon>
<div matLine>
{{ 'labels.heading.E-KYC' | translate }}
@if (arrowBooleans[3]) {
<p matLine [routerLink]="['ekyc']" class="menu-explanation">
{{ 'labels.text.Electronic KYC verification using Aadhaar and biometrics' | translate }}
</p>
}
</div>
</div>
<div class="menu-right-section">
@if (!arrowBooleans[3]) {
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-down" size="md"></fa-icon>
}
@if (arrowBooleans[3]) {
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-up" size="md"></fa-icon>
}
</div>
</div>
</mat-list-item>
</mat-nav-list>
</div> </div>
</div> </div>
</mat-card> <div class="ckyc-dash__hero-badge">
<fa-icon icon="check-circle"></fa-icon>
<span>CERSAI ready</span>
</div>
</div>
<!-- 5-Phase pipeline banner -->
<div class="ckyc-pipeline">
<div class="ckyc-pipeline__head">
<span class="ckyc-pipeline__heading">
<fa-icon icon="stream"></fa-icon> 5-phase compliance pipeline
</span>
<span class="ckyc-pipeline__hint">CBS → CKYC automation flow</span>
</div>
<div class="ckyc-pipeline__track">
<div class="ckyc-phase">
<div class="ckyc-phase__num">1</div>
<div class="ckyc-phase__icon"><fa-icon icon="database"></fa-icon></div>
<div class="ckyc-phase__title">Mark Non-CKYC</div>
<div class="ckyc-phase__desc">Identify missing CKYC registrations</div>
</div>
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
<div class="ckyc-phase">
<div class="ckyc-phase__num">2</div>
<div class="ckyc-phase__icon"><fa-icon icon="file-archive"></fa-icon></div>
<div class="ckyc-phase__title">Auto ZIP</div>
<div class="ckyc-phase__desc">Bundle into CERSAI format</div>
</div>
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
<div class="ckyc-phase ckyc-phase--accent ckyc-phase--wide">
<div class="ckyc-phase__num ckyc-phase__num--dual">3&nbsp;·&nbsp;4</div>
<div class="ckyc-phase__icon"><fa-icon icon="cloud-upload-alt"></fa-icon></div>
<div class="ckyc-phase__title">SFTP Upload &amp; API Match</div>
<div class="ckyc-phase__desc">Transfer · ACK · Resolve CKYC numbers</div>
</div>
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
<div class="ckyc-phase">
<div class="ckyc-phase__num">5</div>
<div class="ckyc-phase__icon"><fa-icon icon="check-double"></fa-icon></div>
<div class="ckyc-phase__title">Status Update</div>
<div class="ckyc-phase__desc">Update master & MIS</div>
</div>
</div>
</div>
<!-- Menu cards -->
<h2 class="ckyc-dash__section-title">
<fa-icon icon="th-large"></fa-icon> Quick actions
</h2>
<div class="ckyc-dash__grid">
<a class="ckyc-card ckyc-card--blue" [routerLink]="['record-master']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="database"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">Record Master</div>
<p class="ckyc-card__desc">Manage master records, identify clients without CKYC and collect missing artefacts.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 1</span>
<span class="ckyc-tag">Clients</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--teal" [routerLink]="['ckyc-verification']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="shield-alt"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">CKYC Verification</div>
<p class="ckyc-card__desc">Batch, upload and match records against the CERSAI registry.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 2-4</span>
<span class="ckyc-tag">Batches</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--amber" [routerLink]="['ckyc-reports']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="chart-pie"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">CKYC Reports</div>
<p class="ckyc-card__desc">Generate compliance MIS, track status counts and export for regulators.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 5</span>
<span class="ckyc-tag">MIS</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--purple" [routerLink]="['ekyc']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="fingerprint"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">E-KYC</div>
<p class="ckyc-card__desc">Electronic KYC verification using Aadhaar OTP and biometric authentication.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Digital</span>
<span class="ckyc-tag">Aadhaar</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
</div>
</div> </div>
+462
View File
@@ -5,3 +5,465 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
$primary: #1a73e8;
$primary-dark: #0c447c;
$primary-light: #e8f0fe;
$gray-50: #f8f9fa;
$gray-100: #f1f3f4;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-700: #5f6368;
$gray-900: #202124;
$white: #fff;
$teal: #0f6e56;
$teal-light: #e1f5ee;
$teal-bg: #f0fbf6;
$amber: #ba7517;
$amber-light: #faeeda;
$amber-bg: #fff8eb;
$purple: #6a4cb3;
$purple-light: #efeafb;
$purple-bg: #f8f5ff;
.ckyc-dash {
padding: 24px 28px 40px;
max-width: 1400px;
margin: 0 auto;
}
/* ---------- HERO ---------- */
.ckyc-dash__hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 24px 28px;
background: linear-gradient(135deg, #0c447c 0%, #1a73e8 55%, #4285f4 100%);
border-radius: 16px;
color: $white;
margin-bottom: 22px;
position: relative;
overflow: hidden;
box-shadow: 0 6px 24px rgba(26, 115, 232, 0.2);
&::before {
content: '';
position: absolute;
top: -40%;
right: -10%;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15), transparent 70%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
position: relative;
}
&-icon {
width: 58px;
height: 58px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
backdrop-filter: blur(10px);
flex-shrink: 0;
}
&-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 30px;
font-size: 12px;
font-weight: 600;
backdrop-filter: blur(10px);
flex-shrink: 0;
position: relative;
fa-icon { color: #9fe1cb; font-size: 14px; }
}
}
.ckyc-dash__title {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
}
.ckyc-dash__sub {
margin: 4px 0 0;
font-size: 13px;
opacity: 0.85;
max-width: 560px;
line-height: 1.5;
}
/* ---------- Pipeline banner ---------- */
.ckyc-pipeline {
background: $white;
border: 1px solid $gray-200;
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 22px;
&__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px dashed $gray-200;
}
&__heading {
font-size: 13px;
font-weight: 700;
color: $gray-900;
display: inline-flex;
align-items: center;
gap: 8px;
fa-icon { color: $primary; }
}
&__hint {
font-size: 11px;
color: $gray-500;
font-weight: 500;
}
&__track {
display: flex;
align-items: stretch;
gap: 4px;
overflow-x: auto;
padding: 4px 2px;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-thumb { background: $gray-200; border-radius: 4px; }
}
}
.ckyc-phase {
flex: 1;
min-width: 140px;
background: $primary-light;
border: 1px solid rgba($primary, 0.15);
border-radius: 10px;
padding: 12px 12px 14px;
text-align: center;
position: relative;
transition: transform 0.15s;
&:hover { transform: translateY(-2px); }
&__num {
position: absolute;
top: 8px;
right: 10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: $primary;
color: $white;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
&--dual {
width: auto;
min-width: 20px;
height: 20px;
padding: 0 8px;
border-radius: 10px;
font-size: 10px;
letter-spacing: 0.5px;
white-space: nowrap;
}
}
&__icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: $primary;
color: $white;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
margin: 0 auto 8px;
box-shadow: 0 3px 10px rgba($primary, 0.25);
}
&__title {
font-size: 12px;
font-weight: 700;
color: $primary-dark;
margin-bottom: 3px;
}
&__desc {
font-size: 10px;
color: $gray-700;
line-height: 1.35;
}
&__arrow {
display: flex;
align-items: center;
color: $gray-200;
font-size: 16px;
padding: 0 2px;
flex-shrink: 0;
}
&--accent {
background: $amber-light;
border-color: rgba($amber, 0.25);
.ckyc-phase__icon {
background: $amber;
box-shadow: 0 3px 10px rgba($amber, 0.3);
}
.ckyc-phase__num {
background: $amber;
}
.ckyc-phase__title { color: #633806; }
}
&--wide {
flex: 1.8;
min-width: 210px;
}
}
/* ---------- Section title ---------- */
.ckyc-dash__section-title {
font-size: 14px;
font-weight: 700;
color: $gray-900;
margin: 10px 0 14px;
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
fa-icon { color: $primary; font-size: 14px; }
}
/* ---------- Menu grid ---------- */
.ckyc-dash__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 18px;
}
.ckyc-card {
display: flex;
flex-direction: column;
padding: 22px 22px 18px;
background: $white;
border: 1px solid $gray-200;
border-radius: 16px;
text-decoration: none;
color: $gray-900;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
min-height: 220px;
&__glow {
position: absolute;
top: -50px;
right: -50px;
width: 160px;
height: 160px;
border-radius: 50%;
opacity: 0.12;
pointer-events: none;
transition: transform 0.4s, opacity 0.3s;
}
&:hover {
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
.ckyc-card__glow {
transform: scale(1.25);
opacity: 0.2;
}
.ckyc-card__cta {
background: $primary;
color: $white;
fa-icon { transform: translateX(3px); }
}
}
&__icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: $white;
margin-bottom: 16px;
position: relative;
z-index: 1;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
&__body {
flex: 1;
position: relative;
z-index: 1;
}
&__title {
font-size: 16px;
font-weight: 700;
color: $gray-900;
margin-bottom: 6px;
letter-spacing: -0.2px;
}
&__desc {
font-size: 12px;
color: $gray-700;
line-height: 1.55;
margin: 0 0 12px;
}
&__tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
&__cta {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 8px 14px;
border-radius: 10px;
background: $gray-50;
color: $gray-700;
font-size: 12px;
font-weight: 600;
margin-top: 14px;
border: 1px solid $gray-200;
transition: all 0.2s;
position: relative;
z-index: 1;
fa-icon {
font-size: 11px;
transition: transform 0.2s;
}
}
/* ------ Colour variants ------ */
&--blue {
.ckyc-card__icon {
background: linear-gradient(135deg, $primary, #4285f4);
}
.ckyc-card__glow {
background: radial-gradient(circle, $primary, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $primary;
border-color: $primary;
}
}
&--teal {
.ckyc-card__icon {
background: linear-gradient(135deg, $teal, #1d9e75);
}
.ckyc-card__glow {
background: radial-gradient(circle, $teal, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $teal;
border-color: $teal;
}
}
&--amber {
.ckyc-card__icon {
background: linear-gradient(135deg, $amber, #d4892f);
}
.ckyc-card__glow {
background: radial-gradient(circle, $amber, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $amber;
border-color: $amber;
}
}
&--purple {
.ckyc-card__icon {
background: linear-gradient(135deg, $purple, #8a6fd4);
}
.ckyc-card__glow {
background: radial-gradient(circle, $purple, transparent 60%);
}
&:hover .ckyc-card__cta {
background: $purple;
border-color: $purple;
}
}
}
.ckyc-tag {
display: inline-block;
padding: 3px 9px;
background: $gray-100;
color: $gray-700;
font-size: 10px;
font-weight: 700;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* ---------- Responsive ---------- */
@media (width <= 900px) {
.ckyc-dash { padding: 16px 18px 30px; }
.ckyc-dash__hero { flex-direction: column; align-items: flex-start; }
.ckyc-pipeline__track { flex-direction: row; flex-wrap: nowrap; }
}
@media (width <= 600px) {
.ckyc-dash__grid { grid-template-columns: 1fr; }
.ckyc-phase { min-width: 120px; }
}
+2 -16
View File
@@ -7,11 +7,7 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatNavList, MatListItem } from '@angular/material/list';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -20,17 +16,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./ckyc.component.scss'], styleUrls: ['./ckyc.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatNavList, FaIconComponent
MatListItem,
MatIcon,
FaIconComponent,
MatLine
] ]
}) })
export class CkycComponent { export class CkycComponent {}
arrowBooleans: boolean[] = new Array(4).fill(false);
arrowBooleansToggle(arrowNumber: number) {
this.arrowBooleans[arrowNumber] = !this.arrowBooleans[arrowNumber];
}
}
@@ -6,14 +6,943 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="container"> <div class="rm-container">
<mat-card> <!-- Header -->
<mat-card-content> <div class="rm-header">
<div class="placeholder-content"> <div class="rm-header__left">
<fa-icon icon="database" size="3x" class="placeholder-icon"></fa-icon> <div class="rm-header__icon">
<h3>{{ 'labels.heading.Record Master' | translate }}</h3> <fa-icon icon="database"></fa-icon>
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
</div> </div>
</mat-card-content> <div>
</mat-card> <h2 class="rm-header__title">{{ 'labels.heading.Record Master' | translate }}</h2>
<p class="rm-header__sub">Clients missing CKYC registration or required artefacts</p>
</div>
</div>
<div class="rm-header__actions">
<button mat-stroked-button class="rm-btn rm-btn--ghost" (click)="fetchRecords()">
<fa-icon icon="sync-alt"></fa-icon>
<span>Refresh</span>
</button>
</div>
</div>
<!-- 3-tab filter -->
<div class="rm-tabs">
<button
type="button"
class="rm-tab"
[class.rm-tab--active]="activeTab === 'all'"
(click)="setTab('all')"
>
<fa-icon icon="list"></fa-icon>
<span>All clients</span>
<span class="rm-tab__count">{{ tabCounts.all }}</span>
</button>
<button
type="button"
class="rm-tab rm-tab--amber"
[class.rm-tab--active]="activeTab === 'pending-collection'"
(click)="setTab('pending-collection')"
>
<fa-icon icon="hourglass-half"></fa-icon>
<span>Pending collection</span>
<span class="rm-tab__count">{{ tabCounts.pendingCollection }}</span>
</button>
<button
type="button"
class="rm-tab rm-tab--green"
[class.rm-tab--active]="activeTab === 'ready'"
(click)="setTab('ready')"
>
<fa-icon icon="check-double"></fa-icon>
<span>Ready for CERSAI</span>
<span class="rm-tab__count">{{ tabCounts.ready }}</span>
</button>
</div>
<!-- Filters -->
<form class="rm-filters" [formGroup]="filterForm" (ngSubmit)="applyFilters()">
<div class="rm-filters__grid">
<div class="rm-field">
<label class="rm-label"><fa-icon icon="building"></fa-icon> Office</label>
<select class="rm-input" formControlName="officeId">
<option value="">All offices</option>
@for (o of offices; track o.id) {
<option [value]="o.id">{{ o.name }}</option>
}
</select>
</div>
<div class="rm-field">
<label class="rm-label"><fa-icon icon="cube"></fa-icon> Loan product ID</label>
<input
type="number"
class="rm-input"
formControlName="productId"
placeholder="Optional"
/>
</div>
<div class="rm-field">
<label class="rm-label"><fa-icon icon="calendar"></fa-icon> From date</label>
<input type="date" class="rm-input" formControlName="fromDate" />
</div>
<div class="rm-field">
<label class="rm-label"><fa-icon icon="calendar"></fa-icon> To date</label>
<input type="date" class="rm-input" formControlName="toDate" />
</div>
</div>
<div class="rm-filters__actions">
<button type="button" mat-stroked-button class="rm-btn rm-btn--ghost" (click)="resetFilters()">
<fa-icon icon="times"></fa-icon> <span>Reset</span>
</button>
<button type="submit" mat-flat-button color="primary" class="rm-btn rm-btn--primary">
<fa-icon icon="search"></fa-icon> <span>Apply filters</span>
</button>
</div>
</form>
<!-- Loading / error / empty -->
@if (loading) {
<div class="rm-state rm-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading CKYC records…</span>
</div>
}
@if (error && !loading) {
<div class="rm-state rm-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (!loading && !error && rows.length === 0) {
<div class="rm-state rm-state--empty">
<fa-icon icon="check-circle" size="3x"></fa-icon>
<h3>No records found</h3>
<p>All clients matching your filters have complete CKYC artefacts. 🎉</p>
</div>
}
<!-- Results table -->
@if (!loading && rows.length > 0) {
<div class="rm-results">
<div class="rm-results__head">
<span class="rm-results__count">
<fa-icon icon="list"></fa-icon>
Showing <strong>{{ pageStart }}–{{ pageEnd }}</strong> of
<strong>{{ totalFilteredRecords }}</strong>
</span>
@if (selected.size > 0) {
<span class="rm-results__selected">
<fa-icon icon="check-square"></fa-icon>
<strong>{{ selected.size }}</strong> selected
</span>
}
</div>
<div class="rm-table-wrap">
<table class="rm-table">
<thead>
<tr>
<th class="rm-table__check">
<input
type="checkbox"
[checked]="allSelected"
[indeterminate]="someSelected"
(change)="toggleAll()"
aria-label="Select all"
/>
</th>
<th>Client</th>
<th>Contact</th>
<th>Address</th>
<th class="rm-table__artefacts">Artefacts</th>
<th class="rm-table__completion">Completion</th>
<th class="rm-table__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.clientId) {
<tr
class="rm-row"
[class.rm-row--selected]="isSelected(row.clientId)"
(click)="toggleRow(row.clientId, $event)"
>
<td class="rm-table__check">
<input
type="checkbox"
[checked]="isSelected(row.clientId)"
(click)="$event.stopPropagation()"
(change)="toggleRow(row.clientId)"
[attr.aria-label]="'Select ' + row.fullName"
/>
</td>
<td>
<div class="rm-row__client">
<div class="rm-row__avatar">{{ row.fullName?.charAt(0) || '?' }}</div>
<div class="rm-row__ident">
<div class="rm-row__name">{{ row.fullName }}</div>
<div class="rm-row__account">
<fa-icon icon="hashtag"></fa-icon> {{ row.accountNo }}
</div>
</div>
</div>
</td>
<td>
<div class="rm-row__line">
<fa-icon icon="envelope"></fa-icon>
<span [class.rm-row__missing]="!row.email">{{ row.email || 'No email' }}</span>
</div>
<div class="rm-row__line">
<fa-icon icon="phone"></fa-icon>
<span [class.rm-row__missing]="!row.mobile">{{ row.mobile || 'No mobile' }}</span>
</div>
</td>
<td class="rm-row__address">
<fa-icon icon="map-marker-alt"></fa-icon>
<span [class.rm-row__missing]="!row.address">{{ row.address || 'No address on file' }}</span>
</td>
<td class="rm-table__artefacts">
<div class="rm-row__chips">
<span class="rm-chip" [class.rm-chip--ok]="row.hasPhoto" [class.rm-chip--miss]="!row.hasPhoto" title="Photo">
<fa-icon [icon]="row.hasPhoto ? 'check' : 'times'"></fa-icon> P
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasSignature" [class.rm-chip--miss]="!row.hasSignature" title="Signature">
<fa-icon [icon]="row.hasSignature ? 'check' : 'times'"></fa-icon> S
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasIdProof" [class.rm-chip--miss]="!row.hasIdProof" title="ID Proof">
<fa-icon [icon]="row.hasIdProof ? 'check' : 'times'"></fa-icon> ID
</span>
<span class="rm-chip" [class.rm-chip--ok]="row.hasAddressProof" [class.rm-chip--miss]="!row.hasAddressProof" title="Address Proof">
<fa-icon [icon]="row.hasAddressProof ? 'check' : 'times'"></fa-icon> Ad
</span>
</div>
</td>
<td class="rm-table__completion">
<div
class="rm-completion"
[class.rm-completion--full]="artefactCompletion(row) === 4"
[class.rm-completion--half]="artefactCompletion(row) >= 2 && artefactCompletion(row) < 4"
[class.rm-completion--low]="artefactCompletion(row) < 2"
>
{{ artefactCompletion(row) }}/4
</div>
</td>
<td class="rm-table__actions">
<button
class="rm-action-btn"
(click)="openView(row, $event)"
title="View details"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
class="rm-action-btn rm-action-btn--primary"
(click)="collectCkyc(row, $event)"
title="Collect CKYC"
>
<fa-icon icon="cloud-upload-alt"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="rm-pager">
<div class="rm-pager__size">
<span>Rows per page:</span>
<select class="rm-input rm-input--sm" [value]="limit" (change)="changeLimit($any($event.target).value)">
<option [value]="10">10</option>
<option [value]="25">25</option>
<option [value]="50">50</option>
<option [value]="100">100</option>
</select>
</div>
<div class="rm-pager__nav">
<button
mat-icon-button
class="rm-pager__btn"
[disabled]="!canGoPrev"
(click)="changePage(-1)"
aria-label="Previous page"
>
<fa-icon icon="chevron-left"></fa-icon>
</button>
<span class="rm-pager__label">{{ pageStart }}–{{ pageEnd }}</span>
<button
mat-icon-button
class="rm-pager__btn"
[disabled]="!canGoNext"
(click)="changePage(1)"
aria-label="Next page"
>
<fa-icon icon="chevron-right"></fa-icon>
</button>
</div>
</div>
</div>
}
<!-- Floating action bar (when rows are selected) -->
@if (selected.size > 0) {
<div class="rm-action-bar">
<div class="rm-action-bar__info">
<div class="rm-action-bar__count">
<fa-icon icon="check-square"></fa-icon>
<strong>{{ selected.size }}</strong> selected
</div>
@if (selectedIncompleteCount > 0) {
<span class="rm-action-bar__warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ selectedIncompleteCount }} incomplete
</span>
}
</div>
<div class="rm-action-bar__btns">
<button class="rm-btn rm-btn--ghost-light" (click)="clearSelection()">
<fa-icon icon="times"></fa-icon> <span>Clear</span>
</button>
<button class="rm-btn rm-btn--amber" (click)="openZipDialog()">
<fa-icon icon="file-archive"></fa-icon>
<span>Create Auto ZIP ({{ selected.size }})</span>
<fa-icon icon="arrow-right"></fa-icon>
</button>
</div>
</div>
}
<!-- Client details view dialog -->
@if (showViewDialog && viewDetails) {
<div class="rm-modal" (click)="closeView()">
<div class="rm-modal__card rm-modal__card--wide" (click)="$event.stopPropagation()">
<div class="rm-modal__header">
<div class="rm-modal__title rm-modal__title--blue">
<fa-icon icon="id-card-alt"></fa-icon>
Client KYC details
</div>
<button class="rm-modal__close" (click)="closeView()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="rm-modal__body">
<!-- Client header -->
<div class="rm-view__client">
<div class="rm-view__avatar">
{{ viewDetails.row.fullName?.charAt(0) || '?' }}
</div>
<div class="rm-view__client-info">
<div class="rm-view__client-name">{{ viewDetails.row.fullName }}</div>
<div class="rm-view__client-meta">
<span class="rm-view__cif">
<fa-icon icon="hashtag"></fa-icon> CIF <strong>{{ viewDetails.row.accountNo }}</strong>
</span>
<span class="rm-view__sep">•</span>
<span>
<fa-icon icon="envelope"></fa-icon>
{{ viewDetails.row.email || 'No email' }}
</span>
<span class="rm-view__sep">•</span>
<span>
<fa-icon icon="phone"></fa-icon>
{{ viewDetails.row.mobile || 'No mobile' }}
</span>
</div>
</div>
</div>
@if (viewLoading) {
<div class="rm-view__loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
Loading KYC details…
</div>
}
<!-- KYC snapshot -->
<div class="rm-view__section-title">
<fa-icon icon="shield-alt"></fa-icon> KYC Snapshot
</div>
<div class="rm-view__kpis">
<div class="rm-kpi">
<div class="rm-kpi__label"><fa-icon icon="check-double"></fa-icon> KYC Status</div>
@if (viewDetails.kycStatus) {
<div
class="rm-kpi__value rm-kpi__pill"
[class.rm-kpi__pill--ok]="viewDetails.kycStatusCode === 'clientKycStatus.verified'"
[class.rm-kpi__pill--warn]="viewDetails.kycStatusCode === 'clientKycStatus.pending' || viewDetails.kycStatusCode === 'clientKycStatus.initiated'"
[class.rm-kpi__pill--err]="viewDetails.kycStatusCode === 'clientKycStatus.rejected' || viewDetails.kycStatusCode === 'clientKycStatus.expired'"
>
{{ viewDetails.kycStatus }}
</div>
} @else {
<div class="rm-kpi__value rm-kpi__missing">—</div>
}
</div>
<div class="rm-kpi">
<div class="rm-kpi__label"><fa-icon icon="calendar-check"></fa-icon> KYC Date</div>
<div class="rm-kpi__value">
{{ viewDetails.kycDate || '—' }}
</div>
</div>
<div class="rm-kpi">
<div class="rm-kpi__label"><fa-icon icon="hourglass-half"></fa-icon> Expiry Date</div>
<div class="rm-kpi__value">
{{ viewDetails.expiryDate || '—' }}
</div>
</div>
<div class="rm-kpi">
<div class="rm-kpi__label"><fa-icon icon="exclamation-triangle"></fa-icon> Risk Category</div>
<div class="rm-kpi__value">
{{ viewDetails.riskCategory || '—' }}
</div>
</div>
</div>
<!-- 4 documents -->
<div class="rm-view__section-title">
<fa-icon icon="folder-open"></fa-icon> CKYC Documents
</div>
<div class="rm-view__docs">
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.photo, label: 'Photo / Image of Client', icon: 'user-circle' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.signature, label: 'Signature of Client', icon: 'file-signature' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.idProof, label: 'Identity Proof', icon: 'id-card' }"></ng-container>
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.addressProof, label: 'Address Proof', icon: 'home' }"></ng-container>
</div>
<ng-template #docTile let-slot="slot" let-label="label" let-icon="icon">
<div class="rm-doc" [class.rm-doc--ok]="slot.present" [class.rm-doc--miss]="!slot.present">
<div class="rm-doc__head">
<div class="rm-doc__icon"><fa-icon [icon]="icon"></fa-icon></div>
<div class="rm-doc__body">
<div class="rm-doc__label">{{ label }}</div>
<div class="rm-doc__value">
{{ slot.name }}
@if (slot.key) {
<span class="rm-doc__key">{{ slot.key }}</span>
}
</div>
</div>
<fa-icon
class="rm-doc__state"
[icon]="slot.present ? 'check-circle' : 'times-circle'"
></fa-icon>
</div>
@if (slot.files?.length) {
<div class="rm-doc__files">
@for (file of slot.files; track file.id) {
<div class="rm-file" (click)="openFilePreview(file, $event)" title="Open">
@if (file.previewUrl && file.isImage !== false && !file.isPdf) {
<img [src]="file.previewUrl" class="rm-file__thumb" [alt]="file.name" />
} @else if (file.isPdf) {
<div class="rm-file__thumb rm-file__thumb--icon rm-file__thumb--pdf">
<fa-icon icon="file-pdf"></fa-icon>
</div>
} @else {
<div class="rm-file__thumb rm-file__thumb--icon">
<fa-icon icon="file-alt"></fa-icon>
</div>
}
<div class="rm-file__info">
@if (file.fileName) {
<span class="rm-file__name">{{ file.fileName }}</span>
} @else {
<span class="rm-file__name">{{ file.name }}</span>
}
<fa-icon icon="external-link-alt" class="rm-file__open"></fa-icon>
</div>
</div>
}
</div>
} @else if (slot.present) {
<div class="rm-doc__placeholder">
<fa-icon icon="spinner" animation="spin"></fa-icon>
Loading preview…
</div>
}
</div>
</ng-template>
</div>
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" (click)="closeView()">
<fa-icon icon="times"></fa-icon> Close
</button>
<button class="rm-btn rm-btn--primary" (click)="collectCkycFromView()">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>Collect CKYC</span>
<fa-icon icon="arrow-right"></fa-icon>
</button>
</div>
</div>
</div>
}
<!-- Auto ZIP Pipeline Dialog -->
@if (showZipDialog) {
<div class="rm-modal">
<div class="rm-modal__card">
<div class="rm-modal__header">
<div class="rm-modal__title">
<fa-icon icon="file-archive"></fa-icon>
Auto ZIP Creation
</div>
<button class="rm-modal__close" [disabled]="zipRunning" (click)="closeZipDialog()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="rm-modal__body">
<p class="rm-modal__intro">
Bundling <strong>{{ selected.size }}</strong> client record{{ selected.size === 1 ? '' : 's' }}
into CERSAI-format ZIP. Files will be packaged with an SHA-256 checksum and registered for SFTP upload.
</p>
@if (selectedIncompleteCount > 0 && !zipRunning && !zipResult) {
<div class="rm-modal__warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ selectedIncompleteCount }} record(s) are missing artefacts.</strong>
They will still be packaged but flagged as incomplete in the manifest.
</div>
</div>
}
@if (!zipRunning && !zipResult) {
<div class="rm-field rm-field--remarks">
<label class="rm-label">
<fa-icon icon="sticky-note"></fa-icon> Remarks <span class="rm-label__opt">(optional)</span>
</label>
<textarea
class="rm-input rm-textarea"
rows="2"
placeholder="e.g. Daily CERSAI push — 2026-04-14 15:30"
[(ngModel)]="zipRemarks"
[ngModelOptions]="{ standalone: true }"
></textarea>
</div>
}
<!-- Stepper -->
<ol class="rm-steps">
@for (phase of zipPhases; track phase.key; let i = $index) {
<li
class="rm-step"
[class.rm-step--active]="phase.status === 'active'"
[class.rm-step--done]="phase.status === 'done'"
[class.rm-step--failed]="phase.status === 'failed'"
>
<div class="rm-step__indicator">
@if (phase.status === 'done') {
<fa-icon icon="check"></fa-icon>
} @else if (phase.status === 'active') {
<fa-icon icon="spinner" animation="spin"></fa-icon>
} @else if (phase.status === 'failed') {
<fa-icon icon="times"></fa-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="rm-step__content">
<div class="rm-step__label">
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
</div>
@if (phase.message) {
<div class="rm-step__msg">{{ phase.message }}</div>
}
</div>
</li>
}
</ol>
@if (zipResult && !zipResult.error) {
<div class="rm-modal__result">
<div class="rm-modal__result-head">
<fa-icon icon="check-circle"></fa-icon>
<span>Batch created successfully</span>
</div>
<div class="rm-modal__result-grid">
<div>
<span class="rm-modal__result-label">Batch ID</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ zipResult.batchId }}</span>
</div>
<div>
<span class="rm-modal__result-label">Filename</span>
<span class="rm-modal__result-value">{{ zipResult.filename }}</span>
</div>
<div>
<span class="rm-modal__result-label">Records</span>
<span class="rm-modal__result-value">{{ zipResult.recordCount }}</span>
</div>
@if (zipResult.sizeBytes) {
<div>
<span class="rm-modal__result-label">Size</span>
<span class="rm-modal__result-value">{{ (zipResult.sizeBytes / 1024 / 1024).toFixed(2) }} MB</span>
</div>
}
@if (zipResult.createdOn) {
<div>
<span class="rm-modal__result-label">Created on</span>
<span class="rm-modal__result-value">{{ zipResult.createdOn }}</span>
</div>
}
@if (zipResult.documentId) {
<div>
<span class="rm-modal__result-label">Document ID</span>
<span class="rm-modal__result-value">#{{ zipResult.documentId }}</span>
</div>
}
<div class="rm-modal__result-grid-span">
<span class="rm-modal__result-label">Checksum</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ zipResult.checksum }}</span>
</div>
</div>
</div>
@if (zipResult.manifest && zipResult.manifest.length > 0) {
<div class="rm-manifest">
<button class="rm-manifest__toggle" (click)="toggleManifest()">
<fa-icon [icon]="manifestExpanded ? 'chevron-down' : 'chevron-right'"></fa-icon>
<span><strong>View manifest</strong> — {{ zipResult.manifest.length }} record(s) packaged</span>
<span class="rm-manifest__stats">
<span class="rm-manifest__stat rm-manifest__stat--full">
<fa-icon icon="check-circle"></fa-icon> {{ manifestStats.full }} complete
</span>
@if (manifestStats.partial > 0) {
<span class="rm-manifest__stat rm-manifest__stat--partial">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ manifestStats.partial }} partial
</span>
}
</span>
</button>
@if (manifestExpanded) {
<div class="rm-manifest__body">
<table class="rm-manifest__table">
<thead>
<tr>
<th>Client</th>
<th class="rm-manifest__th-center">P</th>
<th class="rm-manifest__th-center">S</th>
<th class="rm-manifest__th-center">ID</th>
<th class="rm-manifest__th-center">Ad</th>
<th class="rm-manifest__th-right">Total size</th>
</tr>
</thead>
<tbody>
@for (entry of zipResult.manifest; track entry.clientId) {
<tr>
<td>
<div class="rm-manifest__client">
<strong>{{ entry.displayName || 'Client ' + entry.clientId }}</strong>
@if (entry.cifNumber) {
<span class="rm-manifest__cif">
<fa-icon icon="hashtag"></fa-icon>{{ entry.cifNumber }}
</span>
}
</div>
</td>
<td class="rm-manifest__td-center">
<span
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.photo?.packaged"
[class.rm-manifest__chip--miss]="!entry.photo?.packaged"
[title]="entry.photo?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.photo?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="rm-manifest__td-center">
<span
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.signature?.packaged"
[class.rm-manifest__chip--miss]="!entry.signature?.packaged"
[title]="entry.signature?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.signature?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="rm-manifest__td-center">
<span
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.idProof?.packaged"
[class.rm-manifest__chip--miss]="!entry.idProof?.packaged"
[title]="(entry.idProof?.type || 'Identity Proof') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
>
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="rm-manifest__td-center">
<span
class="rm-manifest__chip"
[class.rm-manifest__chip--ok]="entry.addressProof?.packaged"
[class.rm-manifest__chip--miss]="!entry.addressProof?.packaged"
[title]="(entry.addressProof?.type || 'Address Proof') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
>
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="rm-manifest__td-right rm-manifest__size">
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
@if (zipResult?.error) {
<div class="rm-modal__error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Batch creation failed</strong>
<span>{{ zipResult!.error }}</span>
</div>
</div>
}
</div>
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" [disabled]="zipRunning" (click)="closeZipDialog()">
@if (zipResult && !zipResult.error) { Close } @else { Cancel }
</button>
@if (!zipResult || zipResult.error) {
<button
class="rm-btn rm-btn--amber"
[disabled]="zipRunning"
(click)="startZipCreation()"
>
@if (zipRunning) {
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Creating…</span>
} @else {
<fa-icon icon="cogs"></fa-icon>
<span>{{ zipResult?.error ? 'Retry' : 'Start ZIP creation' }}</span>
}
</button>
} @else {
<button class="rm-btn rm-btn--primary" (click)="proceedToUpload()">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>Proceed to SFTP upload</span>
<fa-icon icon="arrow-right"></fa-icon>
</button>
}
</div>
</div>
</div>
}
<!-- SFTP Upload Progress Dialog -->
@if (showUploadDialog) {
<div class="rm-modal">
<div class="rm-modal__card">
<div class="rm-modal__header">
<div class="rm-modal__title rm-modal__title--teal">
<fa-icon icon="cloud-upload-alt"></fa-icon>
SFTP Upload to CERSAI
</div>
<button class="rm-modal__close" [disabled]="uploadRunning" (click)="closeUploadDialog()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="rm-modal__body">
<p class="rm-modal__intro">
Transferring batch to the <strong>CERSAI SFTP</strong> drop server and waiting for
acknowledgement. On success, the returned <strong>CKYC numbers</strong> will be saved
against each client's master record.
</p>
@if (uploadResult?.status === 'ALREADY_UPLOADED') {
<div class="rm-modal__warn rm-modal__warn--blue">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>Batch already uploaded.</strong>
{{ uploadResult?.remarks || 'No action was taken.' }}
</div>
</div>
}
<!-- Stepper -->
<ol class="rm-steps">
@for (phase of uploadPhases; track phase.key; let i = $index) {
<li
class="rm-step"
[class.rm-step--active]="phase.status === 'active'"
[class.rm-step--done]="phase.status === 'done'"
[class.rm-step--failed]="phase.status === 'failed'"
>
<div class="rm-step__indicator">
@if (phase.status === 'done') {
<fa-icon icon="check"></fa-icon>
} @else if (phase.status === 'active') {
<fa-icon icon="spinner" animation="spin"></fa-icon>
} @else if (phase.status === 'failed') {
<fa-icon icon="times"></fa-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="rm-step__content">
<div class="rm-step__label">
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
</div>
@if (phase.message) {
<div class="rm-step__msg">{{ phase.message }}</div>
}
</div>
</li>
}
</ol>
@if (uploadResult && !uploadResult.error) {
<div class="rm-modal__result">
<div class="rm-modal__result-head">
<fa-icon [icon]="uploadResult.status === 'ALREADY_UPLOADED' ? 'info-circle' : 'check-circle'"></fa-icon>
<span>
@if (uploadResult.status === 'ALREADY_UPLOADED') {
Batch already uploaded to CERSAI
} @else {
Upload successful — ACK received
}
</span>
</div>
<div class="rm-modal__result-grid">
<div>
<span class="rm-modal__result-label">CERSAI Reference</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.cersaiReference }}</span>
</div>
<div>
<span class="rm-modal__result-label">Batch ID</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.batchId }}</span>
</div>
<div>
<span class="rm-modal__result-label">Uploaded on</span>
<span class="rm-modal__result-value">{{ uploadResult.uploadedOn }}</span>
</div>
@if (uploadResult.receivedAt) {
<div>
<span class="rm-modal__result-label">Received at</span>
<span class="rm-modal__result-value">{{ uploadResult.receivedAt }}</span>
</div>
}
<div>
<span class="rm-modal__result-label">Records</span>
<span class="rm-modal__result-value">
{{ uploadResult.processedRecords }} / {{ uploadResult.totalRecords }}
</span>
</div>
<div>
<span class="rm-modal__result-label">Status</span>
<span class="rm-modal__result-value">
<span
class="rm-kpi__pill"
[class.rm-kpi__pill--ok]="uploadResult.status === 'SUCCESS'"
[class.rm-kpi__pill--warn]="uploadResult.status === 'ALREADY_UPLOADED'"
>
{{ uploadResult.status }}
</span>
</span>
</div>
<div class="rm-modal__result-grid-span">
<span class="rm-modal__result-label">Checksum sent</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumSent }}</span>
</div>
<div class="rm-modal__result-grid-span">
<span class="rm-modal__result-label">Checksum verified</span>
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumVerified }}</span>
</div>
</div>
</div>
@if (uploadResult.perClientCkycNumbers && uploadResult.perClientCkycNumbers.length > 0) {
<div class="rm-ckyc-numbers">
<div class="rm-ckyc-numbers__title">
<fa-icon icon="id-card-alt"></fa-icon>
Issued CKYC numbers ({{ uploadResult.perClientCkycNumbers.length }})
</div>
<div class="rm-ckyc-numbers__table-wrap">
<table class="rm-ckyc-numbers__table">
<thead>
<tr>
<th>Client</th>
<th>CIF</th>
<th>CKYC Number</th>
<th class="rm-ckyc-numbers__status">Status</th>
</tr>
</thead>
<tbody>
@for (c of uploadResult.perClientCkycNumbers; track c.clientId) {
<tr>
<td>#{{ c.clientId }}</td>
<td class="rm-ckyc-numbers__mono">{{ c.cifNumber }}</td>
<td class="rm-ckyc-numbers__mono rm-ckyc-numbers__ckyc">{{ c.ckycNumber }}</td>
<td class="rm-ckyc-numbers__status">
<span class="rm-kpi__pill rm-kpi__pill--ok">
<fa-icon icon="check-circle"></fa-icon> {{ c.status }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
@if (uploadResult?.error) {
<div class="rm-modal__error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Upload failed</strong>
<span>{{ uploadResult!.error }}</span>
</div>
</div>
}
</div>
<div class="rm-modal__footer">
<button class="rm-btn rm-btn--ghost" [disabled]="uploadRunning" (click)="closeUploadDialog()">
@if (uploadResult && !uploadResult.error) { Close } @else { Cancel }
</button>
@if (!uploadResult || uploadResult.error) {
<button
class="rm-btn rm-btn--amber"
[disabled]="uploadRunning"
(click)="startSftpUpload()"
>
@if (uploadRunning) {
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Uploading…</span>
} @else {
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>{{ uploadResult?.error ? 'Retry upload' : 'Start SFTP upload' }}</span>
}
</button>
} @else {
<button class="rm-btn rm-btn--primary" (click)="goToVerification()">
<fa-icon icon="shield-alt"></fa-icon>
<span>Go to Verification</span>
<fa-icon icon="arrow-right"></fa-icon>
</button>
}
</div>
</div>
</div>
}
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -6,16 +6,891 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../../clients/clients.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
interface CkycRecordRow {
clientId: number;
accountNo: string;
fullName: string;
email: string | null;
mobile: string | null;
address: string | null;
status: number;
statusCode: string;
hasPhoto: boolean;
hasSignature: boolean;
hasIdProof: boolean;
hasAddressProof: boolean;
}
interface ZipPhase {
key: string;
label: string;
icon: string;
status: 'pending' | 'active' | 'done' | 'failed';
message?: string;
}
interface DocFile {
id: number | string;
name: string;
fileName?: string;
type?: string;
previewUrl?: string;
isImage?: boolean;
isPdf?: boolean;
parentEntityId?: number | string;
source: 'client' | 'identifier' | 'photo';
}
interface DocSlot {
present: boolean;
name: string;
key?: string;
files: DocFile[];
}
interface ClientViewDetails {
row: CkycRecordRow;
kycStatus?: string;
kycStatusCode?: string;
kycDate?: string;
expiryDate?: string;
riskCategory?: string;
docs: {
photo: DocSlot;
signature: DocSlot;
idProof: DocSlot;
addressProof: DocSlot;
};
}
type TabKey = 'all' | 'pending-collection' | 'ready';
@Component({ @Component({
selector: 'mifosx-record-master', selector: 'mifosx-record-master',
templateUrl: './record-master.component.html', templateUrl: './record-master.component.html',
styleUrls: ['./record-master.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent FaIconComponent,
ReactiveFormsModule,
FormsModule
] ]
}) })
export class RecordMasterComponent {} export class RecordMasterComponent implements OnInit {
private clientsService = inject(ClientsService);
private router = inject(Router);
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
filterForm!: FormGroup;
offices: any[] = [];
rows: CkycRecordRow[] = [];
allRows: CkycRecordRow[] = []; // raw response before client-side tab filter
totalFilteredRecords = 0;
limit = 25;
offset = 0;
loading = false;
error = '';
/** 3-tab filter: drives which `missing` value is sent & client-side filter on "ready" */
activeTab: TabKey = 'all';
tabCounts = { all: 0, pendingCollection: 0, ready: 0 };
/** Selection state — Set of clientIds */
selected = new Set<number>();
/** Cached proof-type id → kind ('id' | 'address') map */
private proofKindMap = new Map<string, 'id' | 'address'>();
private proofKindLoaded = false;
/** View details dialog state */
showViewDialog = false;
viewLoading = false;
viewDetails: ClientViewDetails | null = null;
/** Auto-ZIP pipeline dialog state */
showZipDialog = false;
zipRunning = false;
zipPhases: ZipPhase[] = [
{ key: 'validate', label: 'Validate records', icon: 'clipboard-check', status: 'pending' },
{ key: 'collect', label: 'Collect artefacts', icon: 'folder-open', status: 'pending' },
{ key: 'package', label: 'Package CERSAI ZIP', icon: 'file-archive', status: 'pending' },
{ key: 'checksum', label: 'Generate checksum', icon: 'fingerprint', status: 'pending' },
{ key: 'register', label: 'Register batch', icon: 'database', status: 'pending' }
];
zipResult: {
id?: number;
batchId?: string;
filename?: string;
checksum?: string;
recordCount?: number;
sizeBytes?: number;
createdOn?: string;
documentId?: number;
uploadedToCersai?: boolean;
manifest?: any[];
error?: string;
} | null = null;
zipRemarks = '';
manifestExpanded = false;
/** SFTP upload dialog state */
showUploadDialog = false;
uploadRunning = false;
uploadPhases: ZipPhase[] = [
{ key: 'connect', label: 'Connect to CERSAI SFTP', icon: 'link', status: 'pending' },
{ key: 'transfer', label: 'Transfer ZIP file', icon: 'cloud-upload-alt', status: 'pending' },
{ key: 'verify', label: 'Verify checksum', icon: 'fingerprint', status: 'pending' },
{ key: 'ack', label: 'Await ACK receipt', icon: 'inbox', status: 'pending' },
{ key: 'register', label: 'Update CKYC numbers', icon: 'id-card-alt', status: 'pending' }
];
uploadResult: {
status?: string;
batchRegistryId?: number;
batchId?: string;
filename?: string;
cersaiReference?: string;
receivedAt?: string;
uploadedOn?: string;
checksumSent?: string;
checksumVerified?: string;
totalRecords?: number;
processedRecords?: number;
perClientCkycNumbers?: any[];
remarks?: string;
error?: string;
} | null = null;
toggleManifest(): void {
this.manifestExpanded = !this.manifestExpanded;
}
formatBytes(bytes: number | undefined): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
get manifestStats() {
if (!this.zipResult?.manifest) return { full: 0, partial: 0, total: 0 };
const total = this.zipResult.manifest.length;
let full = 0;
this.zipResult.manifest.forEach((m: any) => {
if (m.photo?.packaged && m.signature?.packaged && m.idProof?.packaged && m.addressProof?.packaged) {
full++;
}
});
return { full, partial: total - full, total };
}
ngOnInit(): void {
this.filterForm = this.fb.group({
officeId: [''],
productId: [''],
fromDate: [''],
toDate: ['']
});
this.loadOffices();
this.loadProofKinds();
this.fetchRecords();
}
/**
* Workaround for backend bug where /ckyc/record-master returns
* hasIdProof=false even when an active PAN/Aadhaar/etc identifier
* exists. We load the proof-type map once and then per-row override
* hasIdProof / hasAddressProof using /clients/{id}/identifiers.
*
* TODO: remove once backend SQL is fixed.
*/
private loadProofKinds(): void {
this.clientsService
.getKycProofTypes()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
(res?.idProofs || []).forEach((p: any) =>
this.proofKindMap.set(String(p.id), 'id')
);
(res?.addressProofs || []).forEach((p: any) =>
this.proofKindMap.set(String(p.id), 'address')
);
this.proofKindLoaded = true;
// If records are already loaded, re-verify them now
if (this.rows.length > 0) this.reverifyArtefacts();
},
error: () => undefined
});
}
private reverifyArtefacts(): void {
if (!this.proofKindLoaded || this.allRows.length === 0) return;
const requests: { [clientId: number]: any } = {};
this.allRows.forEach((row) => {
requests[row.clientId] = this.clientsService
.getClientIdentifiers(String(row.clientId))
.pipe(catchError(() => of([])));
});
forkJoin(requests)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((map: any) => {
this.allRows.forEach((row) => {
const list = Array.isArray(map[row.clientId])
? map[row.clientId]
: (map[row.clientId] as any)?.pageItems || [];
let hasId = false;
let hasAddr = false;
for (const idf of list) {
const docTypeId = String(idf?.documentType?.id ?? '');
const kind = this.proofKindMap.get(docTypeId);
const isActive =
idf?.status === 'clientIdentifierStatusType.active' ||
idf?.status === 'Active' ||
!idf?.status;
if (!isActive || !kind) continue;
if (kind === 'id') hasId = true;
else if (kind === 'address') hasAddr = true;
}
// Override backend values only when we find something it missed
if (hasId) row.hasIdProof = true;
if (hasAddr) row.hasAddressProof = true;
});
// Re-apply tab filter + counts now that overrides are in place
this.applyTabFilter();
});
}
private loadOffices(): void {
this.clientsService
.getOffices()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
this.offices = Array.isArray(res) ? res : res?.pageItems || [];
},
error: () => undefined
});
}
fetchRecords(newOffset: number | null = null): void {
if (newOffset !== null) this.offset = newOffset;
const v = this.filterForm.value;
this.loading = true;
this.error = '';
// Only the "Pending Collection" tab passes missing=any; All and Ready tabs
// omit the param so complete clients (like client 89) show up too.
const missing = this.activeTab === 'pending-collection' ? 'any' : undefined;
this.clientsService
.getCkycRecordMaster({
officeId: v.officeId || undefined,
productId: v.productId || undefined,
fromDate: this.formatDate(v.fromDate),
toDate: this.formatDate(v.toDate),
missing,
limit: this.limit,
offset: this.offset
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
this.allRows = res?.pageItems || [];
this.totalFilteredRecords = res?.totalFilteredRecords || 0;
this.applyTabFilter();
this.reverifyArtefacts();
this.loading = false;
},
error: (err: any) => {
this.loading = false;
this.error = err?.error?.defaultUserMessage || 'Failed to load CKYC records.';
this.allRows = [];
this.rows = [];
this.totalFilteredRecords = 0;
this.tabCounts = { all: 0, pendingCollection: 0, ready: 0 };
}
});
}
/** Apply the tab's client-side filter and recompute counts */
private applyTabFilter(): void {
if (this.activeTab === 'ready') {
this.rows = this.allRows.filter(
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
);
} else {
this.rows = this.allRows;
}
this.tabCounts = this.computeCounts(this.allRows);
}
private computeCounts(all: CkycRecordRow[]) {
return {
all: all.length,
pendingCollection: all.filter(
(r) => !r.hasPhoto || !r.hasSignature || !r.hasIdProof || !r.hasAddressProof
).length,
ready: all.filter(
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
).length
};
}
setTab(tab: TabKey): void {
if (this.activeTab === tab) return;
this.activeTab = tab;
this.offset = 0;
this.selected.clear();
this.fetchRecords();
}
private formatDate(value: any): string | undefined {
if (!value) return undefined;
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return undefined;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
applyFilters(): void {
this.offset = 0;
this.fetchRecords();
}
resetFilters(): void {
this.filterForm.reset({ officeId: '', productId: '', fromDate: '', toDate: '' });
this.offset = 0;
this.fetchRecords();
}
changePage(direction: -1 | 1): void {
const next = this.offset + direction * this.limit;
if (next < 0) return;
this.offset = next;
this.fetchRecords();
}
changeLimit(newLimit: number): void {
this.limit = Number(newLimit) || 25;
this.offset = 0;
this.fetchRecords();
}
openClient(row: CkycRecordRow): void {
this.router.navigate(['/clients/', row.clientId, 'general']);
}
openView(row: CkycRecordRow, event?: Event): void {
event?.stopPropagation();
this.releaseViewBlobUrls();
this.showViewDialog = true;
this.viewLoading = true;
this.viewDetails = {
row,
docs: {
photo: { present: row.hasPhoto, name: row.hasPhoto ? 'Profile photo' : 'Not uploaded', files: [] },
signature: { present: row.hasSignature, name: row.hasSignature ? 'Client signature' : 'Not uploaded', files: [] },
idProof: { present: row.hasIdProof, name: row.hasIdProof ? 'Identifier' : 'Not uploaded', files: [] },
addressProof: { present: row.hasAddressProof, name: row.hasAddressProof ? 'Identifier' : 'Not uploaded', files: [] }
}
};
forkJoin({
kyc: this.clientsService.getClientKyc(String(row.clientId)).pipe(catchError(() => of(null))),
identifiers: this.clientsService.getClientIdentifiers(String(row.clientId)).pipe(catchError(() => of([]))),
documents: this.clientsService.getClientDocuments(String(row.clientId)).pipe(catchError(() => of([]))),
photo: this.clientsService.getClientProfileImage(String(row.clientId)).pipe(catchError(() => of(null)))
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ kyc, identifiers, documents, photo }) => {
if (!this.viewDetails) return;
// KYC status + dates
if (kyc) {
const code = kyc.kycStatusCode || '';
this.viewDetails.kycStatus = this.formatKycStatus(code);
this.viewDetails.kycStatusCode = code;
this.viewDetails.kycDate = this.formatJavaDate(kyc.verifiedOn || kyc.initiatedOn);
this.viewDetails.expiryDate = this.formatJavaDate(kyc.expiryDate);
this.viewDetails.riskCategory = (kyc.riskCategoryCode || '').replace('kycRiskCategory.', '').toUpperCase();
}
// 1. Photo — from /clients/{id}/images (base64 data URL)
if (photo && typeof photo === 'string' && photo.length > 0) {
this.viewDetails.docs.photo = {
present: true,
name: 'Profile photo',
files: [
{
id: 'client-photo',
name: 'Profile photo',
previewUrl: photo,
isImage: true,
source: 'photo'
}
]
};
}
// 2. Signature — client document named 'clientSignature'
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
if (signatureDoc) {
this.viewDetails.docs.signature = {
present: true,
name: signatureDoc.fileName || 'Client signature',
files: [
{
id: signatureDoc.id,
name: signatureDoc.name,
fileName: signatureDoc.fileName,
type: signatureDoc.type,
parentEntityId: row.clientId,
source: 'client',
isImage: /image/i.test(signatureDoc.type || ''),
isPdf: /pdf/i.test(signatureDoc.type || '')
}
]
};
// Fetch blob for thumbnail
this.clientsService
.getClientSignatureImage(String(row.clientId), String(signatureDoc.id))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (blob: any) => {
if (blob && this.viewDetails?.docs.signature.files[0]) {
this.viewDetails.docs.signature.files[0].previewUrl = URL.createObjectURL(blob);
}
},
error: () => undefined
});
}
// 3 & 4. Identifier ID/Address proof — classify and fetch attached documents
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
let updatedHasId = false;
let updatedHasAddr = false;
for (const idf of idList) {
const docTypeId = String(idf?.documentType?.id ?? '');
const kind = this.proofKindMap.get(docTypeId);
const isActive =
idf?.status === 'clientIdentifierStatusType.active' ||
idf?.status === 'Active' ||
!idf?.status;
if (!isActive || !kind) continue;
const proofName = idf?.documentType?.name || 'Identifier';
const rawKey = idf?.documentKey || '';
const synthetic = rawKey.startsWith(`${proofName} - `);
const slot: DocSlot = {
present: true,
name: proofName,
key: synthetic ? '' : rawKey,
files: []
};
if (kind === 'id' && !updatedHasId) {
this.viewDetails.docs.idProof = slot;
updatedHasId = true;
this.loadIdentifierFiles(idf.id, 'idProof');
} else if (kind === 'address' && !updatedHasAddr) {
this.viewDetails.docs.addressProof = slot;
updatedHasAddr = true;
this.loadIdentifierFiles(idf.id, 'addressProof');
}
}
this.viewLoading = false;
});
}
/** Fetch documents attached to a client_identifier and load previews */
private loadIdentifierFiles(identifierId: number | string, slot: 'idProof' | 'addressProof'): void {
this.clientsService
.getClientIdentificationDocuments(String(identifierId))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (docs: any) => {
const list = Array.isArray(docs) ? docs : (docs as any)?.pageItems || [];
if (!this.viewDetails) return;
const files: DocFile[] = list.map((d: any) => ({
id: d.id,
name: d.name,
fileName: d.fileName,
type: d.type,
parentEntityId: identifierId,
source: 'identifier' as const,
isImage: /image/i.test(d.type || ''),
isPdf: /pdf/i.test(d.type || '')
}));
this.viewDetails.docs[slot].files = files;
// Fetch blob previews
files.forEach((f) => {
this.clientsService
.downloadClientIdentificationDocument(String(identifierId), String(f.id))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (blob: any) => {
if (blob) f.previewUrl = URL.createObjectURL(blob);
},
error: () => undefined
});
});
},
error: () => undefined
});
}
private releaseViewBlobUrls(): void {
if (!this.viewDetails) return;
const all = [
...this.viewDetails.docs.photo.files,
...this.viewDetails.docs.signature.files,
...this.viewDetails.docs.idProof.files,
...this.viewDetails.docs.addressProof.files
];
all.forEach((f) => {
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl);
});
}
openFilePreview(file: DocFile, event: Event): void {
event.stopPropagation();
if (!file.previewUrl) return;
window.open(file.previewUrl, '_blank', 'noopener,noreferrer');
}
closeView(): void {
this.releaseViewBlobUrls();
this.showViewDialog = false;
this.viewDetails = null;
}
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 '';
}
private formatKycStatus(code: string): string {
if (!code) return 'Unknown';
const map: Record<string, string> = {
'clientKycStatus.verified': 'Verified',
'clientKycStatus.initiated': 'Initiated',
'clientKycStatus.pending': 'Pending',
'clientKycStatus.rejected': 'Rejected',
'clientKycStatus.expired': 'Expired'
};
return map[code] || code.replace('clientKycStatus.', '');
}
collectCkycFromView(): void {
if (!this.viewDetails) return;
const row = this.viewDetails.row;
this.closeView();
this.collectCkyc(row, new Event('click'));
}
collectCkyc(row: CkycRecordRow, event: Event): void {
event.stopPropagation();
this.router.navigate(['/clients/', row.clientId, 'ckyc']);
}
createClient(): void {
this.router.navigate(['/clients/create']);
}
artefactCompletion(row: CkycRecordRow): number {
const items = [row.hasPhoto, row.hasSignature, row.hasIdProof, row.hasAddressProof];
return items.filter(Boolean).length;
}
/** Selection helpers */
toggleRow(clientId: number, event?: Event): void {
event?.stopPropagation();
if (this.selected.has(clientId)) this.selected.delete(clientId);
else this.selected.add(clientId);
}
isSelected(clientId: number): boolean {
return this.selected.has(clientId);
}
toggleAll(): void {
if (this.allSelected) {
this.rows.forEach((r) => this.selected.delete(r.clientId));
} else {
this.rows.forEach((r) => this.selected.add(r.clientId));
}
}
get allSelected(): boolean {
return this.rows.length > 0 && this.rows.every((r) => this.selected.has(r.clientId));
}
get someSelected(): boolean {
return this.selected.size > 0 && !this.allSelected;
}
clearSelection(): void {
this.selected.clear();
}
get selectedRows(): CkycRecordRow[] {
return this.rows.filter((r) => this.selected.has(r.clientId));
}
get selectedCompleteCount(): number {
return this.selectedRows.filter((r) => this.artefactCompletion(r) === 4).length;
}
get selectedIncompleteCount(): number {
return this.selectedRows.length - this.selectedCompleteCount;
}
/** Auto-ZIP pipeline */
openZipDialog(): void {
if (this.selected.size === 0) return;
this.showZipDialog = true;
this.zipRunning = false;
this.zipResult = null;
this.manifestExpanded = false;
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
}
closeZipDialog(): void {
if (this.zipRunning) return;
this.showZipDialog = false;
}
startZipCreation(): void {
if (this.zipRunning || this.selected.size === 0) return;
this.zipRunning = true;
this.zipResult = null;
// Reset phases
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
// Kick off visual phase animation for steps 1-4 while the POST is in flight.
// Step 5 (Register batch) is held until the API responds.
this.animatePhases();
// Fire the real backend call
const clientIds = Array.from(this.selected);
const remarks = this.zipRemarks?.trim()
? this.zipRemarks.trim()
: `CERSAI push — ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
this.clientsService
.createCkycBatch({ clientIds, remarks })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
// Complete all remaining phases and populate result
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'done' as const }));
this.zipResult = {
id: res?.id,
batchId: res?.batchId,
filename: res?.filename,
checksum: res?.checksum,
recordCount: res?.recordCount,
sizeBytes: res?.sizeBytes,
createdOn: res?.createdOn,
documentId: res?.documentId,
uploadedToCersai: res?.uploadedToCersai,
manifest: res?.manifest || []
};
this.zipRunning = false;
},
error: (err: any) => {
// Mark current active phase (or last pending) as failed
const activeIdx = this.zipPhases.findIndex((p) => p.status === 'active');
const idx = activeIdx >= 0 ? activeIdx : this.zipPhases.findIndex((p) => p.status === 'pending');
if (idx >= 0) {
this.zipPhases[idx].status = 'failed';
this.zipPhases[idx].message =
err?.error?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'Request failed';
}
this.zipResult = {
error: err?.error?.defaultUserMessage || err?.message || 'Failed to create CKYC batch.'
};
this.zipRunning = false;
}
});
}
/**
* Run phases 1-4 on a short timer as a visual progress indicator
* while the single POST is in flight. Phase 5 is held — it only
* completes when the API response arrives (success or failure).
*/
private animatePhases(): void {
const step = 500; // ms per phase
for (let i = 0; i < this.zipPhases.length - 1; i++) {
setTimeout(() => {
// Don't override a phase that's already done/failed by the API response
if (this.zipPhases[i].status === 'pending') this.zipPhases[i].status = 'active';
}, i * step);
setTimeout(() => {
if (this.zipPhases[i].status === 'active') this.zipPhases[i].status = 'done';
}, (i + 1) * step);
}
// Activate phase 5 after phases 1-4 finish, leave it active until API returns
setTimeout(() => {
const last = this.zipPhases.length - 1;
if (this.zipPhases[last].status === 'pending') this.zipPhases[last].status = 'active';
}, (this.zipPhases.length - 1) * step);
}
proceedToUpload(): void {
if (!this.zipResult?.id) return;
this.showZipDialog = false;
this.openUploadDialog(this.zipResult.id);
}
/** Open the SFTP upload progress dialog for a given batch registry id */
openUploadDialog(batchRegistryId: number): void {
this.showUploadDialog = true;
this.uploadRunning = false;
this.uploadResult = null;
this.uploadPhases = this.uploadPhases.map((p) => ({
...p,
status: 'pending' as const,
message: undefined as string | undefined
}));
// Stash the id for retry
this.pendingUploadBatchId = batchRegistryId;
}
private pendingUploadBatchId: number | null = null;
closeUploadDialog(): void {
if (this.uploadRunning) return;
this.showUploadDialog = false;
this.pendingUploadBatchId = null;
}
startSftpUpload(): void {
if (this.uploadRunning || !this.pendingUploadBatchId) return;
this.uploadRunning = true;
this.uploadResult = null;
this.uploadPhases = this.uploadPhases.map((p) => ({
...p,
status: 'pending' as const,
message: undefined as string | undefined
}));
// Animate phases 1-4 while POST is in flight; phase 5 held until response.
this.animateUploadPhases();
this.clientsService
.uploadCkycBatchToSftp(this.pendingUploadBatchId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (res: any) => {
this.uploadPhases = this.uploadPhases.map((p) => ({ ...p, status: 'done' as const }));
this.uploadResult = {
status: res?.status,
batchRegistryId: res?.batchRegistryId,
batchId: res?.batchId,
filename: res?.filename,
cersaiReference: res?.cersaiReference,
receivedAt: this.formatJavaDateTime(res?.receivedAt),
uploadedOn: this.formatJavaDateTime(res?.uploadedOn),
checksumSent: res?.checksumSent,
checksumVerified: res?.checksumVerified,
totalRecords: res?.totalRecords,
processedRecords: res?.processedRecords,
perClientCkycNumbers: res?.perClientCkycNumbers || [],
remarks: res?.remarks
};
this.uploadRunning = false;
},
error: (err: any) => {
const activeIdx = this.uploadPhases.findIndex((p) => p.status === 'active');
const idx = activeIdx >= 0 ? activeIdx : this.uploadPhases.findIndex((p) => p.status === 'pending');
if (idx >= 0) {
this.uploadPhases[idx].status = 'failed';
this.uploadPhases[idx].message =
err?.error?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'Upload failed';
}
this.uploadResult = {
error: err?.error?.defaultUserMessage || err?.message || 'SFTP upload failed.'
};
this.uploadRunning = false;
}
});
}
private formatJavaDateTime(value: any): string {
if (value == null || value === '') return '';
const pad = (n: any) => String(n ?? 0).padStart(2, '0');
if (Array.isArray(value) && value.length >= 3) {
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
const date = `${pad(d)}-${pad(mo)}-${y}`;
if (value.length >= 4) {
return `${date} ${pad(h)}:${pad(mi)}:${pad(s)}`;
}
return date;
}
if (typeof value === 'string') {
const dt = new Date(value);
if (!isNaN(dt.getTime())) {
return `${pad(dt.getDate())}-${pad(dt.getMonth() + 1)}-${dt.getFullYear()} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
}
return value;
}
return '';
}
private animateUploadPhases(): void {
const step = 550;
for (let i = 0; i < this.uploadPhases.length - 1; i++) {
setTimeout(() => {
if (this.uploadPhases[i].status === 'pending') this.uploadPhases[i].status = 'active';
}, i * step);
setTimeout(() => {
if (this.uploadPhases[i].status === 'active') this.uploadPhases[i].status = 'done';
}, (i + 1) * step);
}
setTimeout(() => {
const last = this.uploadPhases.length - 1;
if (this.uploadPhases[last].status === 'pending') this.uploadPhases[last].status = 'active';
}, (this.uploadPhases.length - 1) * step);
}
goToVerification(): void {
this.closeUploadDialog();
this.router.navigate(['/ckyc/ckyc-verification']);
}
get pageStart(): number {
return this.totalFilteredRecords === 0 ? 0 : this.offset + 1;
}
get pageEnd(): number {
return this.offset + this.rows.length;
}
get canGoPrev(): boolean {
return this.offset > 0;
}
get canGoNext(): boolean {
return this.rows.length === this.limit;
}
}
@@ -41,11 +41,11 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 24px 16px; padding: 24px 16px;
color: rgba(0, 0, 0, 0.45); color: rgb(0 0 0 / 45%);
font-size: 14px; font-size: 14px;
background: rgba(0, 0, 0, 0.02); background: rgb(0 0 0 / 2%);
border-radius: 6px; border-radius: 6px;
border: 1px dashed rgba(0, 0, 0, 0.12); border: 1px dashed rgb(0 0 0 / 12%);
.empty-icon { .empty-icon {
font-size: 18px; font-size: 18px;
@@ -58,7 +58,7 @@
.panel-icon { .panel-icon {
margin-right: 8px; margin-right: 8px;
font-size: 13px; font-size: 13px;
color: rgba(0, 0, 0, 0.45); color: rgb(0 0 0 / 45%);
} }
} }
@@ -85,7 +85,7 @@
.detail-label { .detail-label {
font-size: 12px; font-size: 12px;
color: rgba(0, 0, 0, 0.5); color: rgb(0 0 0 / 50%);
font-weight: 500; font-weight: 500;
} }
@@ -94,7 +94,7 @@
color: #333; color: #333;
} }
@media (max-width: 768px) { @media (width <= 768px) {
.section-header { .section-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -12,7 +12,7 @@
&__left { display: flex; align-items: center; gap: 12px; } &__left { display: flex; align-items: center; gap: 12px; }
&__icon { font-size: 18px; color: #60a5fa; } &__icon { font-size: 18px; color: #60a5fa; }
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; } &__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
&__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; } &__sub { font-size: 11px; color: rgb(255 255 255 / 50%); margin: 2px 0 0; }
} }
.ec-chips-label { .ec-chips-label {
@@ -133,6 +133,7 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
mat-checkbox { mat-checkbox {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -197,8 +198,9 @@
margin-top: 12px; margin-top: 12px;
} }
@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } } @media (width <= 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) {
@media (width <= 600px) {
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; } .ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
.ec-check-grid { grid-template-columns: 1fr; } .ec-check-grid { grid-template-columns: 1fr; }
.ec-card-head { padding: 12px 14px; } .ec-card-head { padding: 12px 14px; }
@@ -55,7 +55,7 @@
background: #fff; background: #fff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 220px; max-height: 220px;
overflow-y: auto; overflow-y: auto;
} }
@@ -113,6 +113,7 @@
&__left { flex-shrink: 0; } &__left { flex-shrink: 0; }
&__icon { color: #1074b9; } &__icon { color: #1074b9; }
&__photo { &__photo {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -123,6 +124,7 @@
&__info { flex: 1; display: flex; flex-direction: column; } &__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 14px; font-weight: 600; color: #1e293b; } &__name { font-size: 14px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; } &__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
&__sig { &__sig {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -139,6 +141,7 @@
background: #fff; background: #fff;
padding: 2px 4px; padding: 2px 4px;
} }
span { span {
font-size: 9px; font-size: 9px;
color: #94a3b8; color: #94a3b8;
@@ -225,6 +228,7 @@
background: #dcfce7; background: #dcfce7;
color: #16a34a; color: #16a34a;
} }
&.not-verified { &.not-verified {
background: #fef3c7; background: #fef3c7;
color: #d97706; color: #d97706;
@@ -266,7 +270,7 @@
&:hover { &:hover {
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
} }
} }
@@ -309,6 +313,7 @@
width: 100%; width: 100%;
&__icon { color: #1074b9; font-size: 20px; } &__icon { color: #1074b9; font-size: 20px; }
&__name { &__name {
flex: 1; flex: 1;
font-size: 13px; font-size: 13px;
@@ -318,6 +323,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
&__remove { &__remove {
color: #94a3b8 !important; color: #94a3b8 !important;
width: 28px !important; width: 28px !important;
@@ -1,7 +1,7 @@
.zoomable { .zoomable {
cursor: zoom-in; cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease; transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); } &:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgb(0 0 0 / 18%); }
} }
::ng-deep .image-zoom-panel { ::ng-deep .image-zoom-panel {
@@ -42,7 +42,7 @@
&__sub { &__sub {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
margin: 2px 0 0; margin: 2px 0 0;
} }
} }
@@ -252,7 +252,7 @@
&--delete:hover mat-icon { color: #ef4444; } &--delete:hover mat-icon { color: #ef4444; }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.fm-step__header { .fm-step__header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -358,6 +358,7 @@
.nom-card__info { flex: 1; } .nom-card__info { flex: 1; }
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; } .nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; } .nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
.nom-card__sig { .nom-card__sig {
flex-shrink: 0; flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; } img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
@@ -445,6 +446,7 @@
/* Search */ /* Search */
.nom-search-wrapper { position: relative; } .nom-search-wrapper { position: relative; }
.nom-search-field { width: 100%; } .nom-search-field { width: 100%; }
.nom-search-prefix { .nom-search-prefix {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -460,7 +462,7 @@
background: #fff; background: #fff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
max-height: 240px; max-height: 240px;
overflow-y: auto; overflow-y: auto;
margin-top: -12px; margin-top: -12px;
@@ -517,6 +519,7 @@
.nom-selected__info { flex: 1; } .nom-selected__info { flex: 1; }
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; } .nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; } .nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
.nom-selected__sig { .nom-selected__sig {
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; } img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
@@ -599,7 +602,7 @@
button { display: flex; align-items: center; gap: 6px; } button { display: flex; align-items: center; gap: 6px; }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.nom-form-fields { grid-template-columns: 1fr; } .nom-form-fields { grid-template-columns: 1fr; }
.nom-guardian-card { flex-wrap: wrap; } .nom-guardian-card { flex-wrap: wrap; }
.nom-guardian-rel { width: 100%; } .nom-guardian-rel { width: 100%; }
@@ -619,6 +619,7 @@
<span class="sig-table__col sig-table__col--name">Name</span> <span class="sig-table__col sig-table__col--name">Name</span>
<span class="sig-table__col sig-table__col--id">Client ID</span> <span class="sig-table__col sig-table__col--id">Client ID</span>
<span class="sig-table__col sig-table__col--pan">PAN</span> <span class="sig-table__col sig-table__col--pan">PAN</span>
<span class="sig-table__col sig-table__col--din">DIN</span>
<span class="sig-table__col sig-table__col--status">Status</span> <span class="sig-table__col sig-table__col--status">Status</span>
<span class="sig-table__col sig-table__col--actions">Actions</span> <span class="sig-table__col sig-table__col--actions">Actions</span>
</div> </div>
@@ -645,6 +646,17 @@
{{ sig.panNumber || '-' }} {{ sig.panNumber || '-' }}
@if (sig.panVerified) { <mat-icon class="verify-tick-sm">check_circle</mat-icon> } @if (sig.panVerified) { <mat-icon class="verify-tick-sm">check_circle</mat-icon> }
</span> </span>
<span class="sig-table__col sig-table__col--din">
@if (sig.dinNumber) {
{{ sig.dinNumber }}
@if (sig.dinVerified) { <mat-icon class="verify-tick-sm">check_circle</mat-icon> }
@if (!sig.dinVerified) { <mat-icon class="verify-cross-sm">cancel</mat-icon> }
} @else {
<button mat-stroked-button class="din-verify-btn" type="button" matTooltip="Verify DIN" (click)="openDinVerifyDialog(sig)">
<mat-icon class="din-verify-icon">badge</mat-icon> Verify DIN
</button>
}
</span>
<span class="sig-table__col sig-table__col--status"> <span class="sig-table__col sig-table__col--status">
<span class="sig-status-chip" [class.active]="sig.status?.value === 'Active'">{{ sig.status?.value || '-' }}</span> <span class="sig-status-chip" [class.active]="sig.status?.value === 'Active'">{{ sig.status?.value || '-' }}</span>
</span> </span>
@@ -13,7 +13,7 @@ form {
.section-block { .section-block {
margin-bottom: 24px; margin-bottom: 24px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid rgb(0 0 0 / 6%);
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -28,7 +28,7 @@ form {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin: 0 0 16px 0; margin: 0 0 16px;
.section-icon { .section-icon {
color: var(--md-sys-color-primary, #1074b9); color: var(--md-sys-color-primary, #1074b9);
@@ -235,7 +235,7 @@ form {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
} }
.guardian-section__header { .guardian-section__header {
@@ -268,7 +268,7 @@ form {
.guardian-section__sub { .guardian-section__sub {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.55); color: rgb(255 255 255 / 55%);
} }
.guardian-section__age-chips { .guardian-section__age-chips {
@@ -280,9 +280,9 @@ form {
.guardian-age-chip { .guardian-age-chip {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.8); color: rgb(255 255 255 / 80%);
padding: 3px 10px; padding: 3px 10px;
background: rgba(255, 255, 255, 0.1); background: rgb(255 255 255 / 10%);
border-radius: 10px; border-radius: 10px;
strong { strong {
@@ -638,12 +638,13 @@ form {
padding: 0 !important; padding: 0 !important;
border-radius: 12px !important; border-radius: 12px !important;
} }
.mdc-dialog__surface { .mdc-dialog__surface {
border-radius: 12px !important; border-radius: 12px !important;
} }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.guardian-section__header { .guardian-section__header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -738,7 +739,7 @@ form {
background: #fff; background: #fff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 280px; max-height: 280px;
overflow-y: auto; overflow-y: auto;
} }
@@ -834,7 +835,7 @@ form {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
} }
.guardian-card__header { .guardian-card__header {
@@ -945,7 +946,7 @@ form {
height: 16px; height: 16px;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); box-shadow: 0 0 0 1px rgb(0 0 0 / 10%);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1030,7 +1031,7 @@ form {
width: 24px; width: 24px;
height: 24px; height: 24px;
border: none; border: none;
background: rgba(255, 255, 255, 0.15); background: rgb(255 255 255 / 15%);
border-radius: 4px; border-radius: 4px;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
@@ -1040,7 +1041,7 @@ form {
font-size: 12px; font-size: 12px;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.25); background: rgb(255 255 255 / 25%);
} }
} }
@@ -1201,6 +1202,7 @@ form {
.mdc-notched-outline__trailing { .mdc-notched-outline__trailing {
border-color: #10b981 !important; border-color: #10b981 !important;
} }
input:disabled { input:disabled {
color: #059669 !important; color: #059669 !important;
-webkit-text-fill-color: #059669 !important; -webkit-text-fill-color: #059669 !important;
@@ -1213,6 +1215,7 @@ form {
.mdc-notched-outline__trailing { .mdc-notched-outline__trailing {
border-color: #0ea5e9 !important; border-color: #0ea5e9 !important;
} }
input:disabled { input:disabled {
color: #0284c7 !important; color: #0284c7 !important;
-webkit-text-fill-color: #0284c7 !important; -webkit-text-fill-color: #0284c7 !important;
@@ -1225,6 +1228,7 @@ form {
.mdc-notched-outline__trailing { .mdc-notched-outline__trailing {
border-color: #f59e0b !important; border-color: #f59e0b !important;
} }
input:disabled { input:disabled {
color: #d97706 !important; color: #d97706 !important;
-webkit-text-fill-color: #d97706 !important; -webkit-text-fill-color: #d97706 !important;
@@ -1390,7 +1394,7 @@ form {
} }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1505,6 +1509,7 @@ form {
&--name { flex: 1; font-size: 13px; display: flex; align-items: center; gap: 6px; } &--name { flex: 1; font-size: 13px; display: flex; align-items: center; gap: 6px; }
&--id { flex: 0 0 100px; font-size: 12px; color: #64748b; } &--id { flex: 0 0 100px; font-size: 12px; color: #64748b; }
&--pan { flex: 0 0 120px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; } &--pan { flex: 0 0 120px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
&--din { flex: 0 0 140px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
&--status { flex: 0 0 80px; } &--status { flex: 0 0 80px; }
&--actions { flex: 0 0 80px; display: flex; gap: 2px; } &--actions { flex: 0 0 80px; display: flex; gap: 2px; }
} }
@@ -1534,6 +1539,32 @@ form {
color: #22c55e; color: #22c55e;
} }
.verify-cross-sm {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #f59e0b;
}
.din-verify-btn {
font-size: 11px !important;
padding: 2px 8px !important;
min-height: 24px !important;
line-height: 20px !important;
border-radius: 6px !important;
display: flex;
align-items: center;
gap: 4px;
color: #1074b9 !important;
border-color: #93c5fd !important;
.din-verify-icon {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
}
}
.sig-status-chip { .sig-status-chip {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -1561,11 +1592,13 @@ form {
overflow: visible !important; overflow: visible !important;
max-height: none !important; max-height: none !important;
} }
.mdc-dialog__surface { .mdc-dialog__surface {
border-radius: 12px !important; border-radius: 12px !important;
overflow: visible !important; overflow: visible !important;
max-height: none !important; max-height: none !important;
} }
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: none !important; max-height: none !important;
overflow: visible !important; overflow: visible !important;
@@ -1625,7 +1658,7 @@ form {
&__sub { &__sub {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
margin: 2px 0 0; margin: 2px 0 0;
} }
} }
@@ -40,6 +40,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component'; import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
import { DipVerifyDialogComponent } from './dip-verify-dialog.component'; import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
/** /**
* Create Client Component * Create Client Component
@@ -824,6 +825,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.syncSignatoryNames(); this.syncSignatoryNames();
this.fetchClientFullDetails(client.id, sig); this.fetchClientFullDetails(client.id, sig);
this.openDinVerifyDialog(sig);
}
/** Open DIN verification dialog for a signatory */
openDinVerifyDialog(sig: any): void {
const cid = sig.id?.toString();
if (!cid) return;
const entityName = this.createClientForm.get('fullname')?.value || '';
const openDialog = (kycId: string, panFromKyc?: string) => {
const ref = this.dialog.open(DinVerifyDialogComponent, {
width: '620px',
maxWidth: '95vw',
disableClose: false,
autoFocus: true,
data: {
clientId: cid,
kycId,
signatoryName: sig.displayName || '',
cifNumber: sig.cifNumber || sig.accountNumber || cid,
panNumber: sig.panNumber || panFromKyc || '-',
entityName
}
});
ref.afterClosed().subscribe((result: any) => {
if (result && !result.skipped) {
sig.dinNumber = result.dinNumber || '';
sig.dinVerified = result.dinVerified || false;
sig.directorName = result.directorName || '';
sig.dinStatus = result.dinStatus || '';
sig.dinDesignation = result.dinDesignation || '';
sig.companyMatch = result.companyMatch ?? null;
sig.matchedCompany = result.matchedCompany || '';
}
});
};
// Fetch KYC ID first, then open dialog
this.clientService.getClientKyc(cid).subscribe({
next: (kyc: any) => openDialog(kyc?.id?.toString() || '', kyc?.panNumber),
error: () => openDialog('')
});
} }
removeSignatoryAt(index: number): void { removeSignatoryAt(index: number): void {
@@ -0,0 +1,651 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from 'app/clients/clients.service';
@Component({
selector: 'mifosx-din-verify-dialog',
template: `
<h2 mat-dialog-title>
<fa-icon icon="id-badge" class="dialog-title-icon"></fa-icon>
DIN Verification
</h2>
<mat-dialog-content>
<!-- Step 1: Enter DIN -->
@if (!verifying && !verified && !failed && !errorOccurred) {
<div class="din-entry">
<div class="signatory-info">
<fa-icon icon="user-shield" class="sig-icon"></fa-icon>
<div class="sig-detail">
<span class="sig-name">{{ signatoryName }}</span>
<span class="sig-meta">CIF: {{ cifNumber }} &middot; PAN: {{ panNumber }}</span>
</div>
</div>
@if (entityName) {
<div class="entity-info">
<fa-icon icon="building" class="entity-icon"></fa-icon>
<div class="entity-detail">
<span class="entity-label">Entity</span>
<span class="entity-name">{{ entityName }}</span>
</div>
</div>
}
<div class="din-info-bar">
<fa-icon icon="info-circle" class="info-icon"></fa-icon>
<span>Enter the <strong>Director Identification Number (DIN)</strong> issued by the Ministry of Corporate Affairs (MCA) for this authorized signatory. The system will verify the DIN and match the entity name against the director's associated companies.</span>
</div>
<mat-form-field appearance="outline" class="din-input-field">
<mat-label>DIN Number</mat-label>
<mat-icon matPrefix class="din-prefix-icon">badge</mat-icon>
<input matInput [(ngModel)]="dinNumber" placeholder="e.g. 01234567" maxlength="8"
(keyup.enter)="onVerify()" autocomplete="off" />
<mat-hint>8-digit DIN number from MCA</mat-hint>
</mat-form-field>
</div>
}
<!-- Step 2: Verifying -->
@if (verifying) {
<div class="progress-section">
<mat-progress-bar mode="determinate" [value]="progress" color="primary"></mat-progress-bar>
<div class="progress-percent">{{ progress }}%</div>
<div class="progress-steps">
@for (step of progressSteps; track step.label; let i = $index) {
<div class="progress-step" [class.active]="i === currentStep" [class.done]="i < currentStep">
<fa-icon [icon]="i < currentStep ? 'check-circle' : step.icon" [class.done-icon]="i < currentStep"></fa-icon>
<span>{{ step.label }}</span>
</div>
}
</div>
<p class="verify-msg">
Verifying DIN <strong>{{ dinNumber }}</strong> for <strong>{{ signatoryName }}</strong>
against <strong>MCA (Ministry of Corporate Affairs)</strong> records.
</p>
</div>
}
<!-- Step 3a: Verified -->
@if (!verifying && verified) {
<div class="result-card success-card">
<div class="result-icon-ring success-ring">
<fa-icon icon="check-circle" class="result-shield success-shield"></fa-icon>
</div>
<div class="result-badge success-badge">VERIFIED</div>
<h3 class="result-title">DIN Verified Successfully</h3>
<p class="result-detail">
DIN <strong class="din-highlight">{{ dinNumber }}</strong> for
<strong>{{ signatoryName }}</strong> has been verified against
<strong>MCA</strong> records.
</p>
<div class="result-meta">
<div class="meta-row">
<span class="meta-label">DIN</span>
<span class="meta-value meta-mono">{{ dinNumber }}</span>
</div>
@if (directorName) {
<div class="meta-row">
<span class="meta-label">Director Name</span>
<span class="meta-value">{{ directorName }}</span>
</div>
}
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="meta-value meta-success">{{ dinStatus || 'Active' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Verified On</span>
<span class="meta-value">{{ verifiedDate }}</span>
</div>
@if (dinDesignation) {
<div class="meta-row">
<span class="meta-label">Designation</span>
<span class="meta-value">{{ dinDesignation }}</span>
</div>
}
</div>
<!-- Company Match Section -->
@if (companies.length > 0) {
<div class="company-match-section">
<h4 class="company-match-title">
<fa-icon icon="building" class="company-title-icon"></fa-icon>
Associated Companies ({{ companies.length }})
</h4>
@if (companyMatch) {
<div class="match-banner match-success">
<fa-icon icon="check-circle" class="match-banner-icon"></fa-icon>
<div>
<strong>Entity Match Found</strong>
<p><strong>{{ entityName }}</strong> matches <strong>{{ matchedCompany }}</strong></p>
</div>
</div>
} @else {
<div class="match-banner match-warning">
<fa-icon icon="exclamation-triangle" class="match-banner-icon"></fa-icon>
<div>
<strong>No Entity Match</strong>
<p><strong>{{ entityName }}</strong> does not match any of the director's associated companies.</p>
</div>
</div>
}
<div class="company-list">
@for (company of companies; track company.companyName || $index) {
<div class="company-item" [class.company-matched]="company._matched">
@if (company._matched) {
<mat-icon class="company-match-icon match">check_circle</mat-icon>
} @else {
<mat-icon class="company-match-icon no-match">radio_button_unchecked</mat-icon>
}
<div class="company-info">
<span class="company-name">{{ company.companyName }}</span>
<span class="company-meta">
@if (company.cin) { CIN: {{ company.cin }} }
@if (company.cin && company.designation) { &middot; }
@if (company.designation) { {{ company.designation }} }
@if ((company.cin || company.designation) && company.appointmentDate) { &middot; }
@if (company.appointmentDate) { Since: {{ company.appointmentDate }} }
</span>
</div>
</div>
}
</div>
</div>
}
</div>
}
<!-- Step 3b: Verification Failed -->
@if (!verifying && failed) {
<div class="result-card fail-card">
<div class="result-icon-ring fail-ring">
<fa-icon icon="times-circle" class="result-shield fail-shield"></fa-icon>
</div>
<div class="result-badge fail-badge">FAILED</div>
<h3 class="result-title">DIN Verification Failed</h3>
<p class="result-detail">
DIN <strong class="din-highlight-fail">{{ dinNumber }}</strong> could not be verified.
</p>
@if (failReason) {
<div class="fail-reason">
<fa-icon icon="exclamation-triangle" class="fail-reason-icon"></fa-icon>
<span>{{ failReason }}</span>
</div>
}
</div>
}
<!-- Step 3c: Error -->
@if (!verifying && errorOccurred) {
<div class="result-card error-card">
<fa-icon icon="exclamation-circle" class="error-icon-large"></fa-icon>
<p class="error-msg">Verification failed. Please try again.</p>
<p class="error-detail">{{ errorMessage }}</p>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!verifying && !verified && !failed && !errorOccurred) {
<button mat-stroked-button (click)="onSkip()">Skip</button>
<button mat-raised-button color="primary" [disabled]="!dinNumber || dinNumber.length < 8" (click)="onVerify()">
<fa-icon icon="search"></fa-icon> Verify DIN
</button>
}
@if (!verifying && verified) {
<button mat-raised-button color="primary" (click)="onClose(true)">
<fa-icon icon="check"></fa-icon> Done
</button>
}
@if (!verifying && failed) {
<button mat-stroked-button (click)="onRetry()">Retry</button>
<button mat-raised-button color="warn" (click)="onClose(false)">
<fa-icon icon="times"></fa-icon> Close
</button>
}
@if (!verifying && errorOccurred) {
<button mat-stroked-button (click)="onRetry()">Retry</button>
<button mat-raised-button (click)="onClose(false)">Close</button>
}
</mat-dialog-actions>
`,
styles: [
`
:host {
display: block;
min-width: 520px;
}
h2[mat-dialog-title] {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 16px 24px;
font-size: 18px;
font-weight: 600;
color: #1074b9;
}
.dialog-title-icon { font-size: 20px; }
mat-dialog-content {
padding: 16px 24px;
max-height: 70vh;
overflow: auto;
}
/* Signatory info */
.signatory-info {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #f0f4f8;
border-radius: 10px;
margin-bottom: 10px;
border: 1px solid #e2e8f0;
}
.sig-icon { font-size: 22px; color: #1074b9; }
.sig-detail { display: flex; flex-direction: column; }
.sig-name { font-size: 15px; font-weight: 700; color: #1a2332; }
.sig-meta { font-size: 12px; color: #64748b; margin-top: 2px; }
/* Entity info */
.entity-info {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #fffbeb;
border-radius: 8px;
margin-bottom: 14px;
border: 1px solid #fde68a;
}
.entity-icon { font-size: 18px; color: #b45309; }
.entity-detail { display: flex; flex-direction: column; }
.entity-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #92400e; }
.entity-name { font-size: 14px; font-weight: 700; color: #78350f; }
/* DIN info bar */
.din-info-bar {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
background: #e3f2fd;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
color: #333;
line-height: 1.5;
}
.info-icon { font-size: 16px; color: #1074b9; flex-shrink: 0; margin-top: 2px; }
/* DIN input */
.din-input-field { width: 100%; }
.din-prefix-icon { color: #64748b; margin-right: 4px; }
/* Progress */
.progress-section { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.progress-percent { font-size: 22px; font-weight: 700; color: #1074b9; margin-top: 4px; }
.progress-steps { display: flex; flex-direction: column; gap: 10px; width: 100%; padding: 8px 16px; }
.progress-step { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #9ca3af; transition: color 0.3s ease; }
.progress-step.active { color: #1074b9; font-weight: 600; }
.progress-step.done { color: #388e3c; }
.done-icon { color: #388e3c; }
.verify-msg { font-size: 14px; color: #555; line-height: 1.6; text-align: center; margin: 4px 0 0; }
/* Result cards */
.result-card { display: flex; flex-direction: column; align-items: center; padding: 20px 16px; gap: 12px; }
.result-icon-ring { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.success-ring { background: linear-gradient(135deg, #ecfdf5, #d1fae5); border: 3px solid #a7f3d0; }
.fail-ring { background: linear-gradient(135deg, #fef2f2, #fecaca); border: 3px solid #fca5a5; }
.result-shield { font-size: 28px; }
.success-shield { color: #059669; }
.fail-shield { color: #dc2626; }
.result-badge { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 1.5px; padding: 3px 14px; border-radius: 20px; }
.success-badge { color: #059669; background: #ecfdf5; border: 1px solid #a7f3d0; }
.fail-badge { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; }
.result-title { font-size: 17px; font-weight: 700; color: #111827; margin: 0; }
.result-detail { font-size: 13px; color: #6b7280; text-align: center; line-height: 1.6; margin: 0; max-width: 400px; }
.din-highlight { color: #059669; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.din-highlight-fail { color: #dc2626; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.result-meta { width: 100%; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-top: 4px; }
.meta-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f3f4f6; }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 12px; color: #9ca3af; font-weight: 500; }
.meta-value { font-size: 12px; color: #374151; font-weight: 600; }
.meta-mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; letter-spacing: 0.5px; }
.meta-success { color: #059669; }
/* ═══ Company Match Section ═══ */
.company-match-section {
width: 100%;
margin-top: 8px;
}
.company-match-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
color: #1e293b;
margin: 0 0 10px;
}
.company-title-icon { font-size: 14px; color: #64748b; }
/* Match banner */
.match-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.5;
}
.match-banner p { margin: 2px 0 0; font-size: 12px; }
.match-success {
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
}
.match-success .match-banner-icon { color: #059669; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
.match-warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.match-warning .match-banner-icon { color: #d97706; font-size: 18px; margin-top: 1px; flex-shrink: 0; }
/* Company list */
.company-list {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.company-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
transition: background 0.15s;
}
.company-item:last-child { border-bottom: none; }
.company-item:hover { background: #f8fafc; }
.company-matched {
background: #f0fdf4 !important;
border-left: 3px solid #22c55e;
}
.company-match-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
flex-shrink: 0;
}
.company-match-icon.match { color: #22c55e; }
.company-match-icon.no-match { color: #d1d5db; }
.company-info { display: flex; flex-direction: column; min-width: 0; }
.company-name { font-size: 13px; font-weight: 600; color: #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.company-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
/* Fail reason */
.fail-reason {
display: flex; align-items: center; gap: 10px; width: 100%;
background: #fef2f2; border: 1px solid #fecaca; padding: 10px 14px;
border-radius: 6px; font-size: 13px; color: #991b1b; line-height: 1.5;
}
.fail-reason-icon { font-size: 16px; color: #dc2626; flex-shrink: 0; }
/* Error */
.error-icon-large { font-size: 48px; color: #f57c00; }
.error-msg { font-size: 16px; font-weight: 600; color: #f57c00; margin: 0; text-align: center; }
.error-detail { font-size: 14px; color: #555; text-align: center; }
mat-dialog-actions { padding: 8px 24px 16px; }
mat-dialog-actions button { display: flex; align-items: center; gap: 6px; }
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
FaIconComponent
]
})
export class DinVerifyDialogComponent implements OnInit, OnDestroy {
private dialogRef = inject(MatDialogRef<DinVerifyDialogComponent>);
private data: {
clientId: string;
kycId: string;
signatoryName: string;
cifNumber: string;
panNumber: string;
entityName: string;
} = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
signatoryName = '';
cifNumber = '';
panNumber = '';
entityName = '';
dinNumber = '';
verifying = false;
verified = false;
failed = false;
errorOccurred = false;
errorMessage = '';
failReason = '';
directorName = '';
dinStatus = '';
dinDesignation = '';
verifiedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
/** Companies list from DIN verification response */
companies: any[] = [];
/** Whether entity name matched a company */
companyMatch = false;
/** The matched company name */
matchedCompany = '';
progress = 0;
currentStep = 0;
progressSteps = [
{ label: 'Connecting to MCA portal...', icon: 'globe' as any },
{ label: 'Looking up DIN records...', icon: 'search' as any },
{ label: 'Matching entity with companies...', icon: 'building' as any },
{ label: 'Saving verification...', icon: 'database' as any }
];
private intervalId: any;
private apiResponse: any = null;
private apiError: any = null;
private apiDone = false;
ngOnInit() {
this.signatoryName = this.data?.signatoryName || '';
this.cifNumber = this.data?.cifNumber || '';
this.panNumber = this.data?.panNumber || '-';
this.entityName = this.data?.entityName || '';
}
onVerify() {
if (!this.dinNumber || this.dinNumber.length < 8) return;
this.verifying = true;
this.verified = false;
this.failed = false;
this.errorOccurred = false;
this.apiDone = false;
this.apiResponse = null;
this.apiError = null;
this.progress = 0;
this.currentStep = 0;
this.companies = [];
this.companyMatch = false;
this.matchedCompany = '';
this.dialogRef.disableClose = true;
// Fire API call — pass clientId, kycId, dinNumber and entityName
this.clientsService.verifyDIN(this.data.clientId, this.data.kycId, this.dinNumber, this.entityName).subscribe({
next: (res: any) => {
this.apiResponse = res;
this.apiDone = true;
this.tryShowResult();
},
error: (err: any) => {
this.apiError = err;
this.apiDone = true;
this.tryShowResult();
}
});
// Animate progress
this.intervalId = setInterval(() => {
this.progress += 2;
this.currentStep = Math.min(Math.floor(this.progress / 25), this.progressSteps.length - 1);
if (this.progress >= 100) {
clearInterval(this.intervalId);
this.intervalId = null;
this.tryShowResult();
}
}, 60);
}
private tryShowResult() {
if (!this.apiDone || this.progress < 100) return;
this.verifying = false;
this.dialogRef.disableClose = false;
if (this.apiError) {
this.errorOccurred = true;
this.errorMessage = this.apiError?.error?.defaultUserMessage || this.apiError?.error?.message || this.apiError?.message || 'Unable to verify DIN. Please try again.';
return;
}
const res = this.apiResponse;
const changes = res?.changes || res;
if (changes?.dinVerified || res?.dinVerified) {
this.verified = true;
this.directorName = changes?.directorName || res?.directorName || '';
this.dinStatus = changes?.dinStatus || res?.dinStatus || 'Active';
this.dinDesignation = changes?.dinDesignation || res?.dinDesignation || '';
this.verifiedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
// Process company list from response
const rawCompanies = changes?.companies || res?.companies || [];
this.companies = rawCompanies.map((c: any) => ({
...c,
companyName: c.companyName || c.name || '',
cin: c.cin || c.cinNumber || '',
designation: c.designation || '',
appointmentDate: c.appointmentDate || c.dateOfAppointment || '',
_matched: false
}));
// Match entity name against company list
this.matchCompanyName();
} else {
this.failed = true;
this.failReason = changes?.dinFailReason || res?.failReason || res?.defaultUserMessage || 'DIN could not be verified against MCA records.';
}
}
/** Match the entity name against the list of companies from the DIN response */
private matchCompanyName() {
if (!this.entityName || this.companies.length === 0) return;
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const entityNorm = normalize(this.entityName);
for (const company of this.companies) {
const compNorm = normalize(company.companyName);
// Exact match or substring containment
if (compNorm === entityNorm || compNorm.includes(entityNorm) || entityNorm.includes(compNorm)) {
company._matched = true;
this.companyMatch = true;
this.matchedCompany = company.companyName;
break;
}
}
}
onSkip() {
this.dialogRef.close({ skipped: true });
}
onRetry() {
this.verified = false;
this.failed = false;
this.errorOccurred = false;
this.progress = 0;
this.currentStep = 0;
this.companies = [];
this.companyMatch = false;
this.matchedCompany = '';
}
onClose(verified: boolean) {
this.dialogRef.close({
verified,
dinNumber: this.dinNumber,
directorName: this.directorName,
dinStatus: this.dinStatus,
dinDesignation: this.dinDesignation,
dinVerified: verified,
companyMatch: this.companyMatch,
matchedCompany: this.matchedCompany,
companies: this.companies,
response: this.apiResponse
});
}
ngOnDestroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
}
@@ -38,7 +38,7 @@ $muted: #64748b;
&__sub { &__sub {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
margin: 2px 0 0; margin: 2px 0 0;
} }
} }
@@ -173,7 +173,7 @@ $muted: #64748b;
} }
// Responsive // Responsive
@media (max-width: 768px) { @media (width <= 768px) {
.addr-grid--2, .addr-grid--3 { .addr-grid--2, .addr-grid--3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -305,23 +305,85 @@
<mat-datepicker #incorpDatePicker></mat-datepicker> <mat-datepicker #incorpDatePicker></mat-datepicker>
</mat-form-field> </mat-form-field>
<!-- GSTIN (all non-individual) --> <!-- GSTIN (all non-individual) with Verify -->
<mat-form-field appearance="outline" class="form-field"> <div class="form-field verify-field">
<mat-label>GSTIN Number</mat-label> <mat-form-field appearance="outline" class="verify-input">
<input <mat-label>GSTIN Number</mat-label>
matInput <span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
formControlName="gstinNumber" <input
placeholder="15-character GSTIN" matInput
maxlength="15" formControlName="gstinNumber"
style="text-transform: uppercase" placeholder="15-character GSTIN"
(input)=" maxlength="15"
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false }) style="text-transform: uppercase"
" (input)="
/> kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) { "
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error> />
} @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
</mat-form-field> <mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN --> <!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
@if (constitutionMeta?.value === 'PROPRIETORSHIP') { @if (constitutionMeta?.value === 'PROPRIETORSHIP') {
@@ -263,6 +263,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
entPanVerifyName = ''; entPanVerifyName = '';
entPanVerifiedOn = ''; entPanVerifiedOn = '';
/** GSTIN verification */
gstinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
gstinVerifying = false;
gstinVerifyName = '';
gstinVerifiedOn = '';
gstinVerifyState = '';
/** CIN verification (dummy) */ /** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
cinVerifying = false; cinVerifying = false;
@@ -480,6 +487,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (data.panAadhaarSeedingStatus) { if (data.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus; this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
} }
if (data.gstinVerified) {
this.gstinVerifyStatus = 'VERIFIED';
this.gstinVerifyName = data.gstinTradeName || data.gstinLegalName || '';
this.gstinVerifyState = data.gstinState || '';
const rawGstinDate = data.gstinVerifiedOn;
if (Array.isArray(rawGstinDate) && rawGstinDate.length >= 3) {
this.gstinVerifiedOn = `${rawGstinDate[2]?.toString().padStart(2, '0')}/${rawGstinDate[1]?.toString().padStart(2, '0')}/${rawGstinDate[0]}`;
} else if (rawGstinDate) {
this.gstinVerifiedOn = rawGstinDate;
}
}
} }
// Load existing photo & signature // Load existing photo & signature
this.loadExistingPhoto(); this.loadExistingPhoto();
@@ -772,6 +790,68 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
); );
} }
/** Verify GSTIN via API */
verifyGstin() {
if (!this.clientId) return;
const gstin = this.kycForm.value.gstinNumber;
if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
this.gstinVerifying = true;
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
gstinNumber: gstin,
dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
aadhaarNumberMasked: 'XXXXXXXX0000'
};
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyGSTIN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.gstinVerifying = false;
const changes = res?.changes || res;
if (changes?.gstinVerified === true) {
this.gstinVerifyStatus = 'VERIFIED';
this.gstinVerifyName = changes?.gstinTradeName || changes?.gstinLegalName || '';
this.gstinVerifyState = changes?.gstinState || '';
const rawDate = changes?.gstinVerifiedOn;
if (Array.isArray(rawDate)) {
this.gstinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.gstinVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
changes?.gstinVerificationMessage || 'GSTIN verification failed. Please check the GSTIN number and try again.'
);
}
},
error: (err: any) => {
this.gstinVerifying = false;
this.gstinVerifyStatus = 'FAILED';
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'GSTIN verification failed.'
);
}
});
},
(msg) => {
this.gstinVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify CIN (dummy — no real API) */ /** Verify CIN (dummy — no real API) */
verifyCin() { verifyCin() {
const cin = this.kycForm.value.cinNumber; const cin = this.kycForm.value.cinNumber;
@@ -158,7 +158,7 @@ $navy: #0f172a;
} }
} }
@media (max-width: 600px) { @media (width <= 600px) {
.crop-workspace { .crop-workspace {
flex-direction: column; flex-direction: column;
} }
@@ -32,7 +32,7 @@
&__sub { &__sub {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
margin: 2px 0 0; margin: 2px 0 0;
} }
} }
@@ -51,7 +51,7 @@
padding: 20px 24px; padding: 20px 24px;
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%); background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(0 0 0 / 6%);
} }
.profile-avatar { .profile-avatar {
@@ -96,9 +96,9 @@
font-weight: 500; font-weight: 500;
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
background: rgba(255, 255, 255, 0.7); background: rgb(255 255 255 / 70%);
color: #555; color: #555;
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid rgb(0 0 0 / 8%);
.chip-icon { .chip-icon {
font-size: 10px; font-size: 10px;
@@ -120,7 +120,7 @@
/* ── Info Cards ── */ /* ── Info Cards ── */
.info-card { .info-card {
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
@@ -132,7 +132,7 @@
gap: 8px; gap: 8px;
padding: 12px 20px; padding: 12px 20px;
background: #fafafa; background: #fafafa;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid rgb(0 0 0 / 6%);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
@@ -177,8 +177,8 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04); border-bottom: 1px solid rgb(0 0 0 / 4%);
border-right: 1px solid rgba(0, 0, 0, 0.04); border-right: 1px solid rgb(0 0 0 / 4%);
&:nth-child(3n) { &:nth-child(3n) {
border-right: none; border-right: none;
@@ -195,7 +195,7 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: rgba(0, 0, 0, 0.45); color: rgb(0 0 0 / 45%);
} }
.preview-value { .preview-value {
@@ -204,7 +204,7 @@
color: #1a1a1a; color: #1a1a1a;
&.no-data { &.no-data {
color: rgba(0, 0, 0, 0.3); color: rgb(0 0 0 / 30%);
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
} }
@@ -239,7 +239,7 @@
border-collapse: collapse; border-collapse: collapse;
tr { tr {
border-bottom: 1px solid rgba(0, 0, 0, 0.04); border-bottom: 1px solid rgb(0 0 0 / 4%);
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -254,7 +254,7 @@
.info-label { .info-label {
width: 40%; width: 40%;
font-size: 13px; font-size: 13px;
color: rgba(0, 0, 0, 0.5); color: rgb(0 0 0 / 50%);
font-weight: 500; font-weight: 500;
padding-right: 16px; padding-right: 16px;
} }
@@ -278,17 +278,17 @@
padding: 10px 16px; padding: 10px 16px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: rgba(0, 0, 0, 0.55); color: rgb(0 0 0 / 55%);
text-align: left; text-align: left;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.3px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgb(0 0 0 / 8%);
} }
} }
tbody { tbody {
tr { tr {
border-bottom: 1px solid rgba(0, 0, 0, 0.04); border-bottom: 1px solid rgb(0 0 0 / 4%);
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
@@ -349,7 +349,7 @@
.address-block { .address-block {
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08); border-bottom: 1px dashed rgb(0 0 0 / 8%);
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@@ -381,7 +381,7 @@
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
padding: 20px 0 4px; padding: 20px 0 4px;
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgb(0 0 0 / 8%);
margin-top: 8px; margin-top: 8px;
} }
@@ -394,23 +394,23 @@
} }
.cancel-btn { .cancel-btn {
color: rgba(0, 0, 0, 0.6); color: rgb(0 0 0 / 60%);
height: 44px; height: 44px;
} }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 960px) { @media (width <= 960px) {
.preview-grid { .preview-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.preview-field { .preview-field {
&:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); } &:nth-child(3n) { border-right: 1px solid rgb(0 0 0 / 4%); }
&:nth-child(2n) { border-right: none; } &:nth-child(2n) { border-right: none; }
} }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.profile-card { .profile-card {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
@@ -92,7 +92,7 @@ $red-light: #fce8e6;
margin-bottom: 12px !important; margin-bottom: 12px !important;
border-radius: 10px !important; border-radius: 10px !important;
border: 1px solid $gray-200 !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 { &::ng-deep .mat-expansion-panel-body {
padding: 0 20px 20px !important; padding: 0 20px 20px !important;
@@ -194,11 +194,11 @@ $red-light: #fce8e6;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@media (max-width: 480px) { @media (width <= 480px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -233,7 +233,7 @@ $red-light: #fce8e6;
word-break: break-word; word-break: break-word;
&--mono { &--mono {
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px; font-size: 13px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -24,52 +24,108 @@
</div> </div>
</div> </div>
<!-- Top Section: Read-Only Client Info --> <!-- CERSAI Sync Status hero -->
<mat-card class="ckyc-info-card"> <div class="cersai-hero" [class.cersai-hero--synced]="ckycNumber" [class.cersai-hero--pending]="!ckycNumber">
<mat-card-content> <div class="cersai-hero__left">
<div class="ckyc-info-grid"> <div class="cersai-hero__icon">
<!-- Account Number Dropdown --> @if (ckycNumber) {
<div class="ckyc-info-item"> <fa-icon icon="check-double"></fa-icon>
<span class="ckyc-info-label">{{ 'labels.inputs.Account Number' | translate }}</span> } @else {
<mat-form-field appearance="outline"> <fa-icon icon="hourglass-half"></fa-icon>
<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>
</div> </div>
</mat-card-content> <div class="cersai-hero__text">
</mat-card> <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 --> <!-- Document Upload Section -->
<h4 class="ckyc-section-title">{{ 'labels.heading.CKYC Documents' | translate }}</h4> <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"> <div class="ckyc-upload-grid">
<!-- Address Proof Document --> <!-- Address Proof Document -->
<mat-card class="ckyc-upload-card"> <mat-card class="ckyc-upload-card">
@@ -78,6 +134,41 @@
<mat-icon class="ckyc-upload-icon">home</mat-icon> <mat-icon class="ckyc-upload-icon">home</mat-icon>
<span>{{ 'labels.inputs.Address Proof Document' | translate }}</span> <span>{{ 'labels.inputs.Address Proof Document' | translate }}</span>
</div> </div>
@if (existing.ADDRESS_PROOF.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
</div>
</div>
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@if (existing.ADDRESS_PROOF.documentKey) {
<span class="ckyc-existing__key">{{ existing.ADDRESS_PROOF.documentKey }}</span>
}
@if (existing.ADDRESS_PROOF.fileCount && existing.ADDRESS_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.ADDRESS_PROOF.fileCount }} {{ existing.ADDRESS_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('ADDRESS_PROOF')"
>
<fa-icon [icon]="existing.ADDRESS_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p> <p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row"> <div class="ckyc-file-row">
<input <input
@@ -137,6 +228,7 @@
</p> </p>
</div> </div>
} }
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@@ -147,6 +239,41 @@
<mat-icon class="ckyc-upload-icon">photo_camera</mat-icon> <mat-icon class="ckyc-upload-icon">photo_camera</mat-icon>
<span>{{ 'labels.inputs.Photo Proof Document' | translate }}</span> <span>{{ 'labels.inputs.Photo Proof Document' | translate }}</span>
</div> </div>
@if (existing.PHOTO_PROOF.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
</div>
</div>
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@if (existing.PHOTO_PROOF.documentKey) {
<span class="ckyc-existing__key">{{ existing.PHOTO_PROOF.documentKey }}</span>
}
@if (existing.PHOTO_PROOF.fileCount && existing.PHOTO_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.PHOTO_PROOF.fileCount }} {{ existing.PHOTO_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('PHOTO_PROOF')"
>
<fa-icon [icon]="existing.PHOTO_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p> <p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row"> <div class="ckyc-file-row">
<input <input
@@ -206,6 +333,7 @@
</p> </p>
</div> </div>
} }
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@@ -216,6 +344,32 @@
<mat-icon class="ckyc-upload-icon">person</mat-icon> <mat-icon class="ckyc-upload-icon">person</mat-icon>
<span>{{ 'labels.inputs.Image of Client' | translate }}</span> <span>{{ 'labels.inputs.Image of Client' | translate }}</span>
</div> </div>
@if (existing.CLIENT_IMAGE.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
}
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('CLIENT_IMAGE')"
>
<fa-icon [icon]="existing.CLIENT_IMAGE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p> <p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row"> <div class="ckyc-file-row">
<input <input
@@ -275,6 +429,7 @@
</p> </p>
</div> </div>
} }
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@@ -285,6 +440,36 @@
<mat-icon class="ckyc-upload-icon">draw</mat-icon> <mat-icon class="ckyc-upload-icon">draw</mat-icon>
<span>{{ 'labels.inputs.Signature of Client' | translate }}</span> <span>{{ 'labels.inputs.Signature of Client' | translate }}</span>
</div> </div>
@if (existing.CLIENT_SIGNATURE.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<img [src]="existing.CLIENT_SIGNATURE.previewUrl" alt="Client signature" class="ckyc-existing__thumb ckyc-existing__thumb--wide" />
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('CLIENT_SIGNATURE')"
>
<fa-icon [icon]="existing.CLIENT_SIGNATURE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p> <p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row"> <div class="ckyc-file-row">
<input <input
@@ -344,6 +529,7 @@
</p> </p>
</div> </div>
} }
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@@ -49,37 +49,236 @@
line-height: 18px; line-height: 18px;
} }
.ckyc-info-card { /* ===================== CERSAI Sync Hero ===================== */
margin-bottom: 24px; .cersai-hero {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 26px;
border-radius: 16px;
overflow: hidden;
margin-bottom: 18px;
color: #fff;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.1);
&::before {
content: '';
position: absolute;
top: -60%;
right: -10%;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 70%);
pointer-events: none;
}
&--synced {
background: linear-gradient(135deg, #0f6e56 0%, #1d9e75 55%, #2fb88c 100%);
}
&--pending {
background: linear-gradient(135deg, #8a5a14 0%, #ba7517 55%, #d4892f 100%);
}
&__left {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
position: relative;
flex: 1;
}
&__icon {
width: 60px;
height: 60px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
flex-shrink: 0;
backdrop-filter: blur(10px);
}
&__text {
min-width: 0;
flex: 1;
}
&__tag {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.4);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
backdrop-filter: blur(8px);
}
&__ckyc-label {
font-size: 11px;
font-weight: 600;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 10px 0 4px;
}
&__ckyc-number {
font-size: 24px;
font-weight: 800;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
letter-spacing: 0.5px;
line-height: 1.1;
margin-bottom: 8px;
}
&__meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.95;
flex-wrap: wrap;
fa-icon { font-size: 11px; opacity: 0.9; margin-right: 3px; }
strong { font-weight: 700; }
}
&__sep { opacity: 0.5; }
&__hint {
font-size: 12px;
opacity: 0.9;
line-height: 1.5;
max-width: 480px;
margin-top: 4px;
}
&__pulse {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
backdrop-filter: blur(8px);
flex-shrink: 0;
position: relative;
z-index: 1;
}
&__pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9fe1cb;
box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7);
animation: cersai-pulse 2s infinite;
}
} }
@keyframes cersai-pulse {
0% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(159, 225, 203, 0); }
100% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0); }
}
/* ===================== Client info grid ===================== */
.ckyc-info-grid { .ckyc-info-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px; gap: 12px;
align-items: start; margin-bottom: 22px;
} }
.ckyc-info-item { .ckyc-info-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
padding: 14px 16px;
background: #fff;
border: 1px solid #e8eaed;
border-radius: 12px;
min-width: 0;
transition: all 0.15s;
&:hover {
border-color: #c6dafc;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.06);
}
}
@media (width <= 900px) {
.ckyc-info-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cersai-hero {
flex-direction: column;
align-items: flex-start;
&__left { width: 100%; }
&__icon { width: 52px; height: 52px; font-size: 22px; }
&__ckyc-number { font-size: 20px; }
}
}
@media (width <= 500px) {
.ckyc-info-grid {
grid-template-columns: 1fr;
}
} }
.ckyc-info-label { .ckyc-info-label {
font-size: 11px; font-size: 10px;
font-weight: 600; font-weight: 700;
color: #888; color: #5f6368;
text-transform: uppercase; 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 { .ckyc-info-value {
font-size: 14px; font-size: 13px;
color: #333; color: #202124;
min-height: 20px; 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 { .ckyc-section-title {
margin: 24px 0 12px; margin: 24px 0 12px;
font-weight: 600; font-weight: 600;
@@ -87,31 +286,58 @@
.ckyc-upload-grid { .ckyc-upload-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); grid-template-columns: repeat(2, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
align-items: stretch;
} }
.ckyc-upload-card { .ckyc-upload-card {
border-radius: 12px !important;
border: 1px solid #e8eaed;
box-shadow: none !important;
transition: border-color 0.15s, box-shadow 0.15s;
display: flex;
flex-direction: column;
min-height: 240px;
&:hover {
border-color: #c6dafc;
box-shadow: 0 2px 10px rgba(26, 115, 232, 0.06) !important;
}
.mat-mdc-card-content { .mat-mdc-card-content {
padding: 16px; padding: 18px 20px !important;
display: flex;
flex-direction: column;
height: 100%;
} }
} }
.ckyc-upload-header { .ckyc-upload-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
margin-bottom: 8px; color: #202124;
padding-bottom: 12px;
margin-bottom: 0;
border-bottom: 1px solid #f1f3f4;
} }
.ckyc-upload-icon { .ckyc-upload-icon {
font-size: 20px; font-size: 18px;
height: 20px; height: 32px;
width: 20px; width: 32px;
color: #1976d2; color: #fff;
background: linear-gradient(135deg, #1a73e8, #4285f4);
border-radius: 8px;
display: flex !important;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
flex-shrink: 0;
} }
.ckyc-upload-hint { .ckyc-upload-hint {
@@ -163,6 +389,12 @@
} }
} }
@media (width <= 900px) {
.ckyc-upload-grid {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) { @media (width <= 600px) {
.ckyc-info-grid { .ckyc-info-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -180,3 +412,149 @@
width: 100%; width: 100%;
} }
} }
/* ===================== Already-available banners ===================== */
.ckyc-existing-loading {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: #e8f0fe;
color: #0c447c;
border-radius: 8px;
font-size: 12px;
margin-bottom: 14px;
mat-progress-spinner { display: inline-block; }
}
.ckyc-existing {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 14px 12px;
background: #f0fbf6;
border: 1px solid #cfecdd;
border-radius: 10px;
margin-top: 14px;
flex: 1;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: #1d9e75;
border-radius: 10px 0 0 10px;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
strong {
display: block;
font-size: 13px;
font-weight: 700;
color: #085041;
line-height: 1.2;
}
}
&__ok {
font-size: 20px;
color: #1d9e75;
flex-shrink: 0;
}
&__source {
display: block;
font-size: 11px;
color: #0f6e56;
margin-top: 2px;
font-weight: 500;
}
&__meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #085041;
flex-wrap: wrap;
padding: 8px 10px;
background: #fff;
border: 1px solid #cfecdd;
border-radius: 8px;
> span:first-of-type {
font-weight: 600;
}
fa-icon { color: #1d9e75; font-size: 12px; }
}
&__key {
font-family: 'SF Mono', Consolas, monospace;
font-size: 11px;
background: #e1f5ee;
color: #0f6e56;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid #cfecdd;
font-weight: 600;
}
&__count {
font-size: 10px;
font-weight: 700;
background: #1d9e75;
color: #fff;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
&__thumb {
width: 100px;
height: 100px;
border-radius: 10px;
border: 2px solid #fff;
outline: 1px solid #cfecdd;
background: #fff;
object-fit: cover;
align-self: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
&--wide {
width: 200px;
height: 80px;
object-fit: contain;
}
}
&__replace {
align-self: stretch;
margin-top: auto;
height: 34px !important;
font-size: 11px !important;
font-weight: 600 !important;
border-radius: 8px !important;
border: 1px solid #cfecdd !important;
color: #0f6e56 !important;
background: #fff !important;
padding: 0 12px !important;
letter-spacing: 0.2px;
fa-icon { margin-right: 6px; font-size: 11px; }
&:hover {
background: #e1f5ee !important;
border-color: #1d9e75 !important;
}
}
}
@@ -9,7 +9,7 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of } from 'rxjs'; import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
/** Custom Services */ /** Custom Services */
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
@@ -19,6 +19,17 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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({ @Component({
selector: 'mifosx-ckyc-tab', selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html', templateUrl: './ckyc-tab.component.html',
@@ -41,6 +52,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Top section - read-only client info */ /** Top section - read-only client info */
clientId: string; clientId: string;
clientName: string = ''; clientName: string = '';
clientEmail: string = '';
clientMobile: string = '';
cifNumber: string = '';
ckycNumber: string = '';
allAccounts: any[] = []; allAccounts: any[] = [];
selectedAccount: any = null; selectedAccount: any = null;
selectedAccountType: string = ''; selectedAccountType: string = '';
@@ -48,6 +63,17 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** State */ /** State */
loading = true; loading = true;
ckycData: any = null; 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 */ /** File references */
addressProofFile: File | null = null; addressProofFile: File | null = null;
@@ -80,13 +106,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.loadClientInfo(); this.loadClientInfo();
this.loadAccounts(); this.loadAccounts();
this.loadCkycData(); this.loadCkycData();
this.loadExistingArtefacts();
}); });
} }
private loadClientInfo(): void { private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => { this.route.parent.data.subscribe((data: any) => {
if (data?.clientViewData) { const cv = data?.clientViewData;
this.clientName = data.clientViewData.displayName || ''; 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 { private loadCkycData(): void {
this.loading = true; this.loading = true;
// Read CKYC registry data from /clients/{id}/kyc — that's where the
// SFTP upload flow persists ckycNumber / ckycUploadedOn / ckycBatchId.
this.clientsService this.clientsService
.getClientCkyc(this.clientId) .getClientKyc(this.clientId)
.pipe( .pipe(
takeUntil(this.destroy$), takeUntil(this.destroy$),
catchError(() => { catchError(() => {
@@ -134,9 +167,120 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.subscribe((data: any) => { .subscribe((data: any) => {
this.loading = false; this.loading = false;
this.ckycData = data; 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 { onAccountSelect(accountNo: string): void {
this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null; this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null;
this.selectedAccountType = this.selectedAccount?.accountType || ''; this.selectedAccountType = this.selectedAccount?.accountType || '';
@@ -215,5 +359,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
// Release object URLs
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
});
} }
} }
@@ -22,8 +22,8 @@ $accent: #0ea5e9;
background: #fff !important; background: #fff !important;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(0 0 0 / 6%);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 4px rgb(0 0 0 / 4%);
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@@ -42,10 +42,7 @@ $accent: #0ea5e9;
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; inset: 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); 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; pointer-events: none;
} }
@@ -69,7 +66,7 @@ $accent: #0ea5e9;
height: 72px; height: 72px;
border-radius: 10px; border-radius: 10px;
object-fit: cover; object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.25); border: 2px solid rgb(255 255 255 / 25%);
background: #fff; background: #fff;
} }
@@ -84,9 +81,9 @@ $accent: #0ea5e9;
min-width: 26px !important; min-width: 26px !important;
padding: 0 !important; padding: 0 !important;
border-radius: 5px !important; border-radius: 5px !important;
background: rgba(255, 255, 255, 0.1) !important; background: rgb(255 255 255 / 10%) !important;
color: rgba(255, 255, 255, 0.6) !important; color: rgb(255 255 255 / 60%) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important; border: 1px solid rgb(255 255 255 / 15%) !important;
display: flex !important; display: flex !important;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -96,19 +93,19 @@ $accent: #0ea5e9;
i { font-size: 10px; } i { font-size: 10px; }
&:hover { &:hover {
background: rgba(255, 255, 255, 0.2) !important; background: rgb(255 255 255 / 20%) !important;
color: #fff !important; color: #fff !important;
} }
&--danger:hover { &--danger:hover {
background: rgba(239, 68, 68, 0.35) !important; background: rgb(239 68 68 / 35%) !important;
border-color: rgba(239, 68, 68, 0.45) !important; border-color: rgb(239 68 68 / 45%) !important;
} }
} }
&__sig { &__sig {
font-size: 10px; font-size: 10px;
color: rgba(255, 255, 255, 0.6); color: rgb(255 255 255 / 60%);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
@@ -148,23 +145,23 @@ $accent: #0ea5e9;
border-radius: 4px; border-radius: 4px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
background: rgba(16, 185, 129, 0.2); background: rgb(16 185 129 / 20%);
color: #6ee7b7; color: #6ee7b7;
border: 1px solid rgba(16, 185, 129, 0.25); border: 1px solid rgb(16 185 129 / 25%);
} }
} }
.cv-menu-btn { .cv-menu-btn {
color: rgba(255, 255, 255, 0.7) !important; color: rgb(255 255 255 / 70%) !important;
width: 28px !important; width: 28px !important;
height: 28px !important; height: 28px !important;
min-width: 28px !important; min-width: 28px !important;
padding: 0 !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; border-radius: 5px !important;
line-height: 28px !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 { .actions-menu {
font-size: 14px !important; font-size: 14px !important;
@@ -187,14 +184,14 @@ $accent: #0ea5e9;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 11px; padding: 4px 11px;
background: rgba(255, 255, 255, 0.1); background: rgb(255 255 255 / 10%);
border: 1px solid rgba(255, 255, 255, 0.13); border: 1px solid rgb(255 255 255 / 13%);
border-radius: 5px; border-radius: 5px;
&__label { &__label {
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.4); color: rgb(255 255 255 / 40%);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.4px; letter-spacing: 0.4px;
} }
@@ -203,7 +200,7 @@ $accent: #0ea5e9;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
} }
@@ -220,19 +217,19 @@ $accent: #0ea5e9;
.cv-cell { .cv-cell {
padding: 9px 13px; padding: 9px 13px;
background: rgba(255, 255, 255, 0.07); background: rgb(255 255 255 / 7%);
border-radius: 6px; border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgb(255 255 255 / 8%);
transition: background 0.12s; transition: background 0.12s;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.12); background: rgb(255 255 255 / 12%);
} }
&__label { &__label {
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.45); color: rgb(255 255 255 / 45%);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 2px; margin-bottom: 2px;
@@ -253,9 +250,9 @@ $accent: #0ea5e9;
border-radius: 10px; border-radius: 10px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
&--green { background: rgba(16, 185, 129, 0.2); color: #6ee7b7; } &--green { background: rgb(16 185 129 / 20%); color: #6ee7b7; }
&--yellow { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } &--yellow { background: rgb(245 158 11 / 20%); color: #fcd34d; }
&--red { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } &--red { background: rgb(239 68 68 / 20%); color: #fca5a5; }
} }
.actions-menu { color: #fff !important; } .actions-menu { color: #fff !important; }
@@ -263,7 +260,7 @@ $accent: #0ea5e9;
// ── Tab bar ── // ── Tab bar ──
.cv-tabs { .cv-tabs {
background: #fff; 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 { ::ng-deep .mat-mdc-tab-link {
font-size: 12px !important; font-size: 12px !important;
@@ -292,7 +289,7 @@ $accent: #0ea5e9;
button, button,
mat-icon, mat-icon,
fa-icon { fa-icon {
color: rgba(255, 255, 255, 0.4) !important; color: rgb(255 255 255 / 40%) !important;
font-size: 11px !important; font-size: 11px !important;
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
@@ -311,8 +308,8 @@ $accent: #0ea5e9;
// ── Client menu styling ── // ── Client menu styling ──
::ng-deep .mat-mdc-menu-panel { ::ng-deep .mat-mdc-menu-panel {
border-radius: 10px !important; border-radius: 10px !important;
border: 1px solid rgba(0, 0, 0, 0.06) !important; border: 1px solid rgb(0 0 0 / 6%) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important; box-shadow: 0 8px 30px rgb(0 0 0 / 12%) !important;
min-width: 200px !important; min-width: 200px !important;
padding: 6px 0 !important; padding: 6px 0 !important;
@@ -352,17 +349,17 @@ $accent: #0ea5e9;
} }
// ── Responsive ── // ── Responsive ──
@media (max-width: 1024px) { @media (width <= 1024px) {
.cv-grid { grid-template-columns: repeat(3, 1fr); } .cv-grid { grid-template-columns: repeat(3, 1fr); }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.cv-top { flex-direction: column; align-items: center; text-align: center; } .cv-top { flex-direction: column; align-items: center; text-align: center; }
.cv-name-row { justify-content: center; } .cv-name-row { justify-content: center; }
.cv-ids { justify-content: center; } .cv-ids { justify-content: center; }
.cv-grid { grid-template-columns: repeat(2, 1fr); } .cv-grid { grid-template-columns: repeat(2, 1fr); }
} }
@media (max-width: 480px) { @media (width <= 480px) {
.cv-grid { grid-template-columns: 1fr; } .cv-grid { grid-template-columns: 1fr; }
} }
@@ -0,0 +1,28 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 mat-dialog-title class="icd-title">{{ title }}</h2>
<mat-dialog-content class="icd-body">
<p class="icd-hint">Drag the corners to crop. The result will be saved as a PNG.</p>
<div class="icd-cropper">
<image-cropper
[imageFile]="data.file"
[maintainAspectRatio]="false"
[containWithinAspectRatio]="false"
format="png"
output="blob"
(imageCropped)="imageCropped($event)"
(imageLoaded)="imageLoaded($event)"
(loadImageFailed)="loadImageFailed()"
></image-cropper>
</div>
</mat-dialog-content>
<mat-dialog-actions class="icd-actions">
<button mat-button type="button" (click)="cancel()">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="save()" [disabled]="!croppedBlob">Use cropped image</button>
</mat-dialog-actions>
@@ -0,0 +1,52 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
min-width: 480px;
max-width: 720px;
}
.icd-title {
font-size: 16px;
font-weight: 600;
margin: 0;
padding: 16px 20px 8px;
color: #0f172a;
}
.icd-body {
padding: 0 20px 12px;
}
.icd-hint {
font-size: 12px;
color: #64748b;
margin: 0 0 10px;
}
.icd-cropper {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 8px;
max-height: 60vh;
overflow: hidden;
::ng-deep image-cropper {
display: block;
max-height: 56vh;
}
}
.icd-actions {
padding: 8px 20px 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
@@ -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/. 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"> <div class="fd-header">
<h1 mat-dialog-title class="fd-title"> <h1 mat-dialog-title class="fd-title">
@if (documentIdentifier) { @if (documentIdentifier) {
@@ -23,85 +23,439 @@
</div> </div>
<div class="fd-content"> <div class="fd-content">
<form [formGroup]="uploadDocumentForm" class="fd-form"> @if (documentIdentifier) {
@if (documentIdentifier) { <div class="kyc-intro">
<!-- Identifier fields in 2-column grid --> <i class="fa fa-shield kyc-intro__icon"></i>
<div class="fd-grid fd-grid--cols-2"> <div>
<div class="fd-field"> <strong>KYC Document Upload</strong>
<mat-form-field class="flex-fill" appearance="outline"> <p>
<mat-label>{{ 'labels.inputs.Document Type' | translate }}</mat-label> Upload KYC documents as per RBI guidelines. Accepted:
<mat-select formControlName="documentTypeId" required> <i class="fa fa-file-image-o"></i> JPG,
@for (docType of allowedDocumentTypes; track docType) { <i class="fa fa-file-image-o"></i> PNG,
<mat-option [value]="docType.id"> <i class="fa fa-file-pdf-o"></i> PDF — max 5MB each.
{{ docType.name }} </p>
</mat-option> </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> </select>
@if (uploadDocumentForm.controls.documentTypeId?.hasError('required')) { <i class="fa fa-chevron-down kyc-select-wrap__chevron"></i>
<mat-error> </div>
{{ 'labels.inputs.Document Type' | translate }} {{ 'labels.commons.is' | translate }} </div>
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> @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>
<div class="fd-field"> @if (idMergedPreview && selectedIdProof?.sides === 2) {
<mat-form-field class="flex-fill" appearance="outline"> <div class="kyc-merged">
<mat-label>{{ 'labels.inputs.Status' | translate }}</mat-label> <div class="kyc-merged__head">
<mat-select formControlName="status" required> <span class="kyc-merged__title">
@for (status of statusOptions; track status) { <i class="fa fa-object-group"></i> Combined preview (front + back)
<mat-option [value]="status.value"> </span>
{{ status.label }} <span class="kyc-merged__hint">
</mat-option> Saved as a single PNG. Re-crop a side to update the merged file.
} </span>
</mat-select> </div>
</mat-form-field> <div class="kyc-merged__preview">
</div> <img [src]="idMergedPreview" alt="Merged ID document" />
<div class="fd-field"> </div>
<mat-form-field class="flex-fill" appearance="outline"> <div class="kyc-merged__actions">
<mat-label>{{ 'labels.inputs.Document Key' | translate }}</mat-label> <button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'front')">
<input formControlName="documentKey" required matInput /> <i class="fa fa-crop"></i> Re-crop front
@if (uploadDocumentForm.controls.documentKey?.hasError('required')) { </button>
<mat-error> <button type="button" class="kyc-merged__btn" (click)="reCrop('id', 'back')">
{{ 'labels.inputs.Document Key' | translate }} {{ 'labels.commons.is' | translate }} <i class="fa fa-crop"></i> Re-crop back
<strong>{{ 'labels.commons.required' | translate }}</strong> </button>
</mat-error> </div>
} </div>
</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> </div>
<!-- File section --> <!-- SECTION 2: ADDRESS PROOF -->
<div class="fd-section-divider"> <div class="kyc-section" formGroupName="address">
<span class="fd-section-divider__text"> <div class="kyc-section__header">
<i class="fa fa-paperclip"></i> {{ 'labels.heading.Documents' | translate }} <div class="kyc-badge kyc-badge--teal">
<i class="fa fa-map-marker"></i>
</div>
<div class="kyc-section__titles">
<p class="kyc-section__title">
<i class="fa fa-home"></i> Address proof
</p>
<p class="kyc-section__sub">
Select document type — upload requirements will update automatically
</p>
</div>
</div>
<!-- Prefilled permanent address -->
@if (clientAddress) {
<div class="kyc-prefill kyc-prefill--teal">
<i class="fa fa-map-marker"></i>
<div>
<span class="kyc-prefill__label">Permanent address (from registration)</span>
<span class="kyc-prefill__value">{{ clientAddress }}</span>
</div>
<i class="fa fa-lock kyc-prefill__lock" title="Prefilled from client registration"></i>
</div>
} @else {
<div class="kyc-banner kyc-banner--amber">
<i class="fa fa-exclamation-triangle"></i>
<span>No permanent address found on file. Add one in the Address tab first.</span>
</div>
}
<div class="kyc-field">
<label class="kyc-label">
<i class="fa fa-file-text-o"></i> Document type
</label>
<div class="kyc-chip-wrap">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="kyc-chip-group-label">
<i class="fa fa-folder-open-o"></i> {{ group.label }}
</div>
<div class="kyc-chip-grid">
@for (p of group.items; track p.id) {
<span
class="kyc-chip"
[class.kyc-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
role="button"
>
@if (isAddrProofSelected(p)) {
<i class="fa fa-check-circle"></i>
} @else {
<i class="fa fa-circle-o"></i>
}
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="kyc-chip-divider"></div>
}
}
</div>
</div>
@if (selectedAddrProof?.info) {
<div class="kyc-banner kyc-banner--teal">
<i class="fa fa-check-circle"></i>
<span>{{ selectedAddrProof.info }}</span>
</div>
}
@if (selectedAddrProof) {
<div class="kyc-upload-grid" [class.kyc-upload-grid--two]="selectedAddrProof.sides === 2">
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i>
{{ selectedAddrProof.sides === 2 ? 'Front side' : (selectedAddrProof.name + ' (single copy)') }}
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
</span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="addrFiles.front"
[class.kyc-dropzone--req]="!addrFiles.front"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('address', 'front', $event)"
/>
@if (addrFiles.front) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'front') }}
</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="kyc-field">
<div class="kyc-upload-label">
<span class="kyc-label">
<i class="fa fa-file-image-o"></i> Back side
</span>
<span class="kyc-tag kyc-tag--req">
<i class="fa fa-asterisk"></i> Required
</span>
</div>
<label
class="kyc-dropzone"
[class.kyc-dropzone--has]="addrFiles.back"
[class.kyc-dropzone--req]="!addrFiles.back"
>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf"
hidden
(change)="onFilePick('address', 'back', $event)"
/>
@if (addrFiles.back) {
<i class="fa fa-check-circle kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">File selected</p>
<p class="kyc-dropzone__name">
<i class="fa fa-paperclip"></i> {{ getFileName('address', 'back') }}
</p>
} @else {
<i class="fa fa-cloud-upload kyc-dropzone__icon"></i>
<p class="kyc-dropzone__text">Click to upload</p>
<p class="kyc-dropzone__hint">
<i class="fa fa-file-image-o"></i> JPG ·
<i class="fa fa-file-image-o"></i> PNG ·
<i class="fa fa-file-pdf-o"></i> PDF · max 5MB
</p>
}
</label>
</div>
}
@if (selectedAddrProof.requiresDate) {
<div class="kyc-field kyc-field--date">
<label class="kyc-label">
<i class="fa fa-calendar"></i> Issue / statement date
<span class="kyc-opt">(not older than 3 months)</span>
</label>
<div class="kyc-input-wrap kyc-input-wrap--date">
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
<input
type="date"
class="kyc-input kyc-input--date"
formControlName="issueDate"
/>
</div>
</div>
}
</div>
@if (addrMergedPreview && selectedAddrProof?.sides === 2) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">
<i class="fa fa-object-group"></i> Combined preview (front + back)
</span>
<span class="kyc-merged__hint">
Saved as a single PNG. Re-crop a side to update the merged file.
</span>
</div>
<div class="kyc-merged__preview">
<img [src]="addrMergedPreview" alt="Merged address document" />
</div>
<div class="kyc-merged__actions">
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'front')">
<i class="fa fa-crop"></i> Re-crop front
</button>
<button type="button" class="kyc-merged__btn" (click)="reCrop('address', 'back')">
<i class="fa fa-crop"></i> Re-crop back
</button>
</div>
</div>
}
}
</div>
<div class="kyc-section kyc-section--notes">
<label class="kyc-label">
<i class="fa fa-sticky-note-o"></i> Description <span class="kyc-opt">(optional)</span>
</label>
<textarea
class="kyc-textarea"
formControlName="description"
placeholder="Add any additional notes about these documents…"
rows="2"
></textarea>
</div>
<div class="kyc-status-bar">
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isIdComplete()">
@if (isIdComplete()) { <i class="fa fa-check"></i> }
</span>
<i class="fa fa-id-badge"></i> Identity proof
</span>
<span>
<span class="kyc-dot" [class.kyc-dot--done]="isAddressComplete()">
@if (isAddressComplete()) { <i class="fa fa-check"></i> }
</span>
<i class="fa fa-home"></i> Address proof
</span>
<span class="kyc-status-hint">
<i class="fa fa-info-circle"></i> Fill in at least one section
</span> </span>
</div> </div>
<div class="fd-grid fd-grid--cols-2"> </form>
<div class="fd-field"> } @else {
<mat-form-field class="flex-fill" appearance="outline"> <form [formGroup]="uploadDocumentForm" class="fd-form">
<mat-label>{{ 'labels.inputs.File Name' | translate }}</mat-label>
<input formControlName="fileName" required matInput />
@if (uploadDocumentForm.controls.fileName.hasError('required')) {
<mat-error>
{{ 'labels.inputs.File Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="fd-field fd-field--file">
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
</div>
</div>
} @else {
<!-- Standard upload form -->
<div class="fd-grid fd-grid--cols-2"> <div class="fd-grid fd-grid--cols-2">
<div class="fd-field"> <div class="fd-field">
<mat-form-field class="flex-fill" appearance="outline"> <mat-form-field class="flex-fill" appearance="outline">
@@ -125,24 +479,25 @@
<div class="fd-field fd-field--file"> <div class="fd-field fd-field--file">
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload> <mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
</div> </div>
} </form>
</form> }
</div> </div>
<mat-dialog-actions class="fd-actions" align="end"> <mat-dialog-actions class="fd-actions" align="end">
<button mat-stroked-button mat-dialog-close class="fd-btn fd-btn--cancel"> <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 }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button <button
mat-flat-button mat-flat-button
color="primary" color="primary"
[disabled]="!uploadDocumentForm.valid" [disabled]="!canSubmit"
[mat-dialog-close]="uploadDocumentForm.value" (click)="submit()"
class="fd-btn fd-btn--submit" class="fd-btn fd-btn--submit"
> >
<i class="fa fa-check fd-btn__icon"></i> <i class="fa fa-check fd-btn__icon"></i>
@if (documentIdentifier) { @if (documentIdentifier) {
{{ 'labels.buttons.Add' | translate }} {{ 'labels.buttons.Submit' | translate }}
} @else { } @else {
{{ 'labels.buttons.Upload' | translate }} {{ 'labels.buttons.Upload' | translate }}
} }
@@ -184,8 +184,697 @@ $white: #fff;
} }
} }
@media (max-width: 600px) { @media (width <= 600px) {
.fd-grid--cols-2 { .fd-grid--cols-2 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ===================== KYC Identifier Mode ===================== */
$blue-50: #e6f1fb;
$blue-100: #d4e5f7;
$blue-700: #185fa5;
$blue-900: #0c447c;
$teal-50: #e1f5ee;
$teal-700: #0f6e56;
$teal-900: #085041;
$teal-accent: #1d9e75;
$amber-50: #faeeda;
$amber-700: #ba7517;
$amber-900: #633806;
$red-50: #faece7;
$red-100: #f5c4b3;
$red-700: #d85a30;
$red-900: #993c1d;
.fd-dialog--kyc {
.fd-content {
padding: 18px 24px 8px !important;
max-height: 75vh !important;
overflow-y: auto !important;
}
}
.kyc-intro {
display: flex;
align-items: flex-start;
gap: 12px;
background: linear-gradient(135deg, $blue-50, $teal-50);
border: 1px solid $gray-200;
border-radius: 10px;
padding: 14px 16px;
margin: 0 0 16px;
&__icon {
font-size: 22px;
color: $primary;
flex-shrink: 0;
margin-top: 2px;
}
strong {
font-size: 13px;
color: $gray-900;
display: block;
margin-bottom: 2px;
}
p {
font-size: 12px;
color: $gray-700;
margin: 0;
line-height: 1.5;
i {
color: $primary;
margin: 0 1px;
}
}
}
.kyc-prefill {
display: flex;
align-items: center;
gap: 12px;
background: $blue-50;
border: 1px solid $blue-100;
border-left: 3px solid $blue-700;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 14px;
> i:first-child {
font-size: 16px;
color: $blue-700;
flex-shrink: 0;
}
> div {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 10px;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__value {
font-size: 13px;
color: $blue-900;
font-weight: 500;
word-break: break-word;
}
&__lock {
font-size: 12px;
color: $gray-500;
flex-shrink: 0;
}
&--teal {
background: $teal-50;
border-color: $teal-50;
border-left-color: $teal-700;
> i:first-child { color: $teal-700; }
.kyc-prefill__value { color: $teal-900; }
}
}
.kyc-input-wrap,
.kyc-select-wrap {
position: relative;
display: block;
&__icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: $gray-500;
font-size: 13px;
pointer-events: none;
}
}
.kyc-select-wrap {
&__chevron {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: $gray-500;
font-size: 11px;
pointer-events: none;
}
.kyc-select {
padding-left: 36px;
padding-right: 32px;
background-image: none;
}
}
.kyc-input-wrap .kyc-input,
.kyc-input.kyc-input--padded {
padding-left: 38px;
}
/* Date input — match styling of other kyc inputs */
.kyc-input--date {
font-family: inherit;
color: $gray-900;
cursor: pointer;
min-height: 40px;
padding-right: 14px !important;
&::-webkit-calendar-picker-indicator {
opacity: 0.55;
cursor: pointer;
filter: grayscale(1);
transition: opacity 0.15s;
}
&:hover::-webkit-calendar-picker-indicator { opacity: 0.9; }
&::-webkit-datetime-edit {
color: $gray-900;
padding-top: 1px;
}
&::-webkit-datetime-edit-fields-wrapper { color: $gray-900; }
&::-webkit-datetime-edit-text { color: $gray-500; padding: 0 2px; }
&:invalid::-webkit-datetime-edit,
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
color: $gray-500;
}
}
/* Notes section — textarea */
.kyc-section--notes {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
}
.kyc-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid $gray-200;
border-radius: 8px;
background: $gray-50;
color: $gray-900;
font-size: 13px;
font-family: inherit;
resize: vertical;
min-height: 60px;
transition: border-color 0.15s, background 0.15s;
&::placeholder { color: $gray-500; }
&:focus {
outline: none;
border-color: $primary;
background: $white;
}
}
.kyc-loading {
font-size: 13px;
color: $gray-500;
padding: 8px 0 14px;
i { margin-right: 6px; color: $primary; }
}
.kyc-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.kyc-section {
background: $white;
border: 1px solid $gray-200;
border-radius: 12px;
padding: 18px;
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid $gray-200;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 15px;
font-weight: 600;
color: $gray-900;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
i { color: $primary; font-size: 14px; }
}
&__sub {
font-size: 12px;
color: $gray-500;
margin: 2px 0 0;
}
}
.kyc-badge {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
&--blue {
background: $blue-50;
color: $blue-700;
border: 1px solid $blue-100;
}
&--teal {
background: $teal-50;
color: $teal-700;
border: 1px solid $teal-50;
}
}
.kyc-row {
display: grid;
gap: 12px;
margin-bottom: 12px;
&--2 {
grid-template-columns: 1fr 1fr;
}
}
.kyc-mb-sm { margin-bottom: 12px; }
.kyc-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.kyc-label {
font-size: 12px;
font-weight: 500;
color: $gray-700;
display: inline-flex;
align-items: center;
gap: 6px;
i {
color: $gray-500;
font-size: 12px;
}
}
.kyc-opt {
font-size: 11px;
color: $gray-500;
font-weight: 400;
}
.kyc-input {
width: 100%;
padding: 10px 12px;
border: 1px solid $gray-200;
border-radius: 8px;
background: $gray-50;
color: $gray-900;
font-size: 13px;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
&::placeholder { color: $gray-500; }
&:focus {
outline: none;
border-color: $primary;
background: $white;
}
&:disabled {
background: $gray-100;
color: $gray-500;
cursor: not-allowed;
}
}
.kyc-select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%235f6368' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
.kyc-banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
margin-bottom: 12px;
i { font-size: 14px; flex-shrink: 0; margin-top: 1px; }
&--blue {
background: $blue-50;
color: $blue-900;
border-left: 3px solid $blue-700;
}
&--amber {
background: $amber-50;
color: $amber-900;
border-left: 3px solid $amber-700;
}
&--teal {
background: $teal-50;
color: $teal-900;
border-left: 3px solid $teal-700;
}
}
.kyc-upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 4px;
&--two {
grid-template-columns: 1fr 1fr;
}
}
.kyc-upload-label {
display: flex;
align-items: center;
justify-content: space-between;
}
.kyc-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
&--req {
background: $red-50;
color: $red-900;
}
}
.kyc-dropzone {
display: block;
border: 1.5px dashed $gray-200;
border-radius: 8px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
background: $gray-50;
transition: all 0.15s;
&:hover {
border-color: $primary;
background: $gray-100;
}
&__icon {
font-size: 20px;
color: $gray-500;
display: block;
margin-bottom: 4px;
}
&__text {
font-size: 13px;
color: $gray-700;
margin: 0 0 2px;
font-weight: 500;
}
&__hint {
font-size: 11px;
color: $gray-500;
margin: 0;
}
&__name {
font-size: 11px;
color: $teal-900;
font-weight: 600;
margin: 6px 0 0;
word-break: break-all;
}
&--has {
border-color: $teal-accent;
border-style: solid;
background: $teal-50;
.kyc-dropzone__icon { color: $teal-accent; }
.kyc-dropzone__text { color: $teal-900; }
}
&--req {
border-color: $red-700;
background: $red-50;
&:hover {
background: $red-100;
}
.kyc-dropzone__icon { color: $red-700; }
}
}
.kyc-chip-wrap {
background: $gray-50;
border: 1px solid $gray-200;
border-radius: 8px;
padding: 14px;
}
.kyc-chip-group-label {
font-size: 10px;
font-weight: 600;
color: $gray-500;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
i { color: $gray-500; font-size: 11px; }
}
.kyc-chip-grid {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.kyc-chip-divider {
height: 1px;
background: $gray-200;
margin: 10px 0;
}
.kyc-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
border: 1px solid $gray-200;
background: $white;
color: $gray-700;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
user-select: none;
i { font-size: 11px; color: $gray-500; }
&:hover {
border-color: $teal-700;
color: $teal-900;
i { color: $teal-700; }
}
&--sel {
background: $teal-50;
border-color: $teal-700;
color: $teal-900;
font-weight: 600;
i { color: $teal-accent; }
}
}
.kyc-status-bar {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: $gray-500;
margin-top: 4px;
padding-top: 14px;
border-top: 1px solid $gray-200;
flex-wrap: wrap;
gap: 8px;
}
.kyc-status-hint {
font-style: italic;
}
.kyc-dot {
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
background: $gray-200;
color: $white;
transition: background 0.3s;
font-size: 9px;
&--done {
background: $teal-accent;
}
}
.kyc-status-bar {
span i { color: $gray-500; margin: 0 2px; }
.kyc-status-hint i { color: $primary; }
}
.kyc-merged {
margin-top: 14px;
padding: 12px 14px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 10px;
&__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
&__title {
font-size: 12px;
font-weight: 700;
color: #0c4a6e;
i { margin-right: 6px; }
}
&__hint {
font-size: 11px;
color: #075985;
margin-left: auto;
}
&__preview {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
text-align: center;
max-height: 320px;
overflow: auto;
img {
max-width: 100%;
max-height: 300px;
display: inline-block;
object-fit: contain;
}
}
&__actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
&__btn {
background: #fff;
border: 1px solid #0284c7;
color: #0284c7;
border-radius: 6px;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
transition: all 0.15s;
i { font-size: 11px; }
&:hover {
background: #0284c7;
color: #fff;
}
}
}
@media (width <= 680px) {
.kyc-row--2,
.kyc-upload-grid--two {
grid-template-columns: 1fr;
}
}
@@ -8,16 +8,150 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { import {
MatDialog,
MatDialogRef, MatDialogRef,
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogTitle, MatDialogTitle,
MatDialogActions, MatDialogActions,
MatDialogClose MatDialogClose
} from '@angular/material/dialog'; } 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 { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
import { MatIconButton } from '@angular/material/button'; import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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({ @Component({
selector: 'mifosx-upload-document-dialog', selector: 'mifosx-upload-document-dialog',
@@ -35,67 +169,66 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class UploadDocumentDialogComponent implements OnInit { export class UploadDocumentDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<UploadDocumentDialogComponent>>(MatDialogRef); dialogRef = inject<MatDialogRef<UploadDocumentDialogComponent>>(MatDialogRef);
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
data = inject(MAT_DIALOG_DATA); data = inject(MAT_DIALOG_DATA);
/** Upload Document form. */
uploadDocumentForm: UntypedFormGroup; uploadDocumentForm: UntypedFormGroup;
/** Upload Document Data */
uploadDocumentData: any = []; uploadDocumentData: any = [];
/** Triggers identity fields (documentType, status, documentKey) */
documentIdentifier = false; documentIdentifier = false;
/** Entity Type */
entityType: string; entityType: string;
/** Allowed Document Types for identifiers */
allowedDocumentTypes: any[] = [];
/** Status options for identifiers */
statusOptions: any[] = [];
/** /** Prefilled values from client registration (identifier mode) */
* @param {MatDialogRef} dialogRef Dialog reference element clientName = '';
* @param {FormBuilder} formBuilder Form Builder clientAddress = '';
* @param {any} data Dialog Data
*/ /** 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() { constructor() {
const data = this.data; const data = this.data;
this.documentIdentifier = !!data?.documentIdentifier;
this.documentIdentifier = data.documentIdentifier; this.entityType = data?.entityType;
this.entityType = data.entityType; this.clientName = data?.clientName || '';
this.allowedDocumentTypes = data.allowedDocumentTypes || []; this.clientAddress = data?.clientAddress || '';
this.statusOptions = data.statusOptions || [];
} }
ngOnInit() { ngOnInit() {
this.createUploadDocumentForm(); this.createUploadDocumentForm();
if (this.documentIdentifier) {
this.loadProofTypes();
}
} }
/**
* Creates the upload Document form.
*/
createUploadDocumentForm() { createUploadDocumentForm() {
if (this.documentIdentifier) { if (this.documentIdentifier) {
// Unified form for identity: identifier fields + document upload
this.uploadDocumentForm = this.formBuilder.group({ this.uploadDocumentForm = this.formBuilder.group({
documentTypeId: [ id: this.formBuilder.group({
'', proofId: [''],
Validators.required documentKey: ['']
], }),
status: [ address: this.formBuilder.group({
'Active', proofId: [''],
Validators.required issueDate: ['']
], }),
documentKey: [ description: ['']
'',
Validators.required
],
description: [''],
fileName: [
'',
Validators.required
],
file: ['']
}); });
} else { } else {
// Standard document upload form
this.uploadDocumentForm = this.formBuilder.group({ this.uploadDocumentForm = this.formBuilder.group({
fileName: [ fileName: [
'', '',
@@ -107,10 +240,300 @@ export class UploadDocumentDialogComponent implements OnInit {
} }
} }
private loadProofTypes(): void {
this.loadingProofs = true;
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
this.addressProofs = (res?.addressProofs || [])
.filter(activeFilter)
.map((p: any) => this.normalizeProof(p));
this.buildAddressGroups();
this.loadingProofs = false;
},
error: () => {
this.loadingProofs = false;
}
});
}
private normalizeProof(p: any): ProofType {
const code = (p.code || p.description || '').toString().toUpperCase();
const meta = PROOF_META[code] || {};
return {
id: p.id,
name: p.name,
code,
sides: p.sides || meta.sides || 1,
category: p.category || meta.category || 'Other',
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
info: p.info || meta.info || ''
};
}
private buildAddressGroups(): void {
const map = new Map<string, ProofType[]>();
this.addressProofs.forEach((p) => {
const key = p.category || 'Other';
if (!map.has(key)) map.set(key, []);
map.get(key).push(p);
});
this.addressGroups = Array.from(map.entries()).map(([label, items]) => ({ label, items }));
}
onIdProofChange(proofId: any): void {
this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null;
this.idFiles = { front: null, back: null };
this.idMergedFile = null;
this.idMergedPreview = null;
}
selectAddrProof(proof: ProofType): void {
this.selectedAddrProof = proof;
this.uploadDocumentForm.get('address.proofId').setValue(proof.id);
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
isAddrProofSelected(proof: ProofType): boolean {
return this.selectedAddrProof?.id === proof.id;
}
onFilePick(section: SectionKey, slot: 'front' | 'back', event: any): void {
const file = event?.target?.files?.[0];
// reset the input so picking the same file again still triggers change
if (event?.target) event.target.value = '';
if (!file) return;
// Non-image (e.g. PDF) — bypass cropping
if (!file.type.startsWith('image/')) {
this.setSlotFile(section, slot, file);
this.maybeMerge(section);
return;
}
const ref = this.dialog.open(ImageCropperDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: { file, title: `Crop ${slot} side` }
});
ref.afterClosed().subscribe((cropped: File | null) => {
if (!cropped) return;
this.setSlotFile(section, slot, cropped);
this.maybeMerge(section);
});
}
reCrop(section: SectionKey, slot: 'front' | 'back'): void {
const current = section === 'id' ? this.idFiles[slot] : this.addrFiles[slot];
if (!current || !current.type.startsWith('image/')) return;
const ref = this.dialog.open(ImageCropperDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: { file: current, title: `Re-crop ${slot} side` }
});
ref.afterClosed().subscribe((cropped: File | null) => {
if (!cropped) return;
this.setSlotFile(section, slot, cropped);
this.maybeMerge(section);
});
}
private setSlotFile(section: SectionKey, slot: 'front' | 'back', file: File): void {
if (section === 'id') this.idFiles[slot] = file;
else this.addrFiles[slot] = file;
}
/** /**
* Sets file form control value. * If a section has both image sides ready, build a single merged PNG
* @param {any} $event file change event. * (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) { onFileSelect($event: any) {
if ($event.target.files.length > 0) { if ($event.target.files.length > 0) {
const file = $event.target.files[0]; const file = $event.target.files[0];
@@ -8,6 +8,7 @@
.ec-header { .ec-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
h3 { font-size: 16px; font-weight: 600; color: #1e293b; display: flex; align-items: center; gap: 8px; h3 { font-size: 16px; font-weight: 600; color: #1e293b; display: flex; align-items: center; gap: 8px;
fa-icon { color: #1565c0; } fa-icon { color: #1565c0; }
} }
@@ -55,6 +56,6 @@
.ec-card__label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; font-weight: 500; } .ec-card__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__value { font-size: 13px; font-weight: 500; color: #1e293b; }
@media (max-width: 768px) { @media (width <= 768px) {
.ec-card__grid { grid-template-columns: repeat(2, 1fr); } .ec-card__grid { grid-template-columns: repeat(2, 1fr); }
} }
@@ -113,7 +113,7 @@ $orange-light: #fff3e0;
margin-bottom: 12px !important; margin-bottom: 12px !important;
border-radius: 10px !important; border-radius: 10px !important;
border: 1px solid $gray-200 !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 { &::ng-deep .mat-expansion-panel-body {
padding: 0 20px 20px !important; padding: 0 20px 20px !important;
@@ -224,11 +224,11 @@ $orange-light: #fff3e0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@media (max-width: 480px) { @media (width <= 480px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -263,7 +263,7 @@ $orange-light: #fff3e0;
word-break: break-word; word-break: break-word;
&--mono { &--mono {
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px; font-size: 13px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -176,7 +176,7 @@ $white: #fff;
} }
// Responsive // Responsive
@media (max-width: 600px) { @media (width <= 600px) {
.fd-grid--cols-2 { .fd-grid--cols-2 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -36,6 +36,7 @@ $bg: #F0F4F8;
} }
&__icon { font-size: 40px; width: 40px; height: 40px; color: $green; } &__icon { font-size: 40px; width: 40px; height: 40px; color: $green; }
&__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; } &__title { font-size: 20px; font-weight: 700; color: $text; margin: 0 0 8px; }
&__desc { &__desc {
font-size: 15px; color: $text; margin: 0 0 6px; font-size: 15px; color: $text; margin: 0 0 6px;
display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap; display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap;
@@ -67,10 +68,12 @@ $bg: #F0F4F8;
h3 { margin: 0; font-size: 15px; font-weight: 600; color: $text; flex: 1; } 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 { &__status {
font-size: 11px; font-weight: 600; color: $green; background: $green-light; font-size: 11px; font-weight: 600; color: $green; background: $green-light;
padding: 3px 10px; border-radius: 12px; padding: 3px 10px; border-radius: 12px;
} }
&__edit { &__edit {
font-size: 12px !important; height: 30px !important; border-radius: 6px !important; font-size: 12px !important; height: 30px !important; border-radius: 6px !important;
mat-icon { font-size: 16px; width: 16px; height: 16px; margin-right: 4px; } mat-icon { font-size: 16px; width: 16px; height: 16px; margin-right: 4px; }
@@ -81,11 +84,14 @@ $bg: #F0F4F8;
/* ── Detail Grid (read-only) ── */ /* ── Detail Grid (read-only) ── */
.f60-detail-grid { .f60-detail-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
@media (width <= 600px) { grid-template-columns: 1fr; }
} }
.f60-detail { .f60-detail {
display: flex; flex-direction: column; display: flex; flex-direction: column;
&--full { grid-column: 1 / -1; } &--full { grid-column: 1 / -1; }
&__label { &__label {
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
color: $muted; letter-spacing: 0.5px; margin-bottom: 4px; color: $muted; letter-spacing: 0.5px; margin-bottom: 4px;
@@ -98,7 +104,8 @@ $bg: #F0F4F8;
/* ── Form Grid ── */ /* ── Form Grid ── */
.f60-form-grid { .f60-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
@media (width <= 600px) { grid-template-columns: 1fr; }
} }
.f60-ff { width: 100%; &--full { grid-column: 1 / -1; } } .f60-ff { width: 100%; &--full { grid-column: 1 / -1; } }
@@ -24,7 +24,7 @@ $gray-300: #dadce0;
$gray-600: #5f6368; $gray-600: #5f6368;
$gray-700: #3c4043; $gray-700: #3c4043;
$gray-900: #202124; $gray-900: #202124;
$white: #ffffff; $white: #fff;
$red: #d93025; $red: #d93025;
// ── Main Container ── // ── Main Container ──
@@ -41,7 +41,7 @@ $red: #d93025;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: $gray-900; color: $gray-900;
margin: 0 0 16px 0; margin: 0 0 16px;
} }
} }
@@ -58,12 +58,12 @@ $red: #d93025;
background: $white; background: $white;
border-radius: 10px; border-radius: 10px;
padding: 18px 20px; 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; 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 { &: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); transform: translateY(-2px);
} }
@@ -106,6 +106,7 @@ $red: #d93025;
// Color variants // Color variants
&--blue { &--blue {
border-left-color: $primary-blue; border-left-color: $primary-blue;
.stat-card__icon { .stat-card__icon {
background: $primary-blue-light; background: $primary-blue-light;
color: $primary-blue; color: $primary-blue;
@@ -114,6 +115,7 @@ $red: #d93025;
&--green { &--green {
border-left-color: $green; border-left-color: $green;
.stat-card__icon { .stat-card__icon {
background: $green-light; background: $green-light;
color: $green; color: $green;
@@ -122,6 +124,7 @@ $red: #d93025;
&--purple { &--purple {
border-left-color: $purple; border-left-color: $purple;
.stat-card__icon { .stat-card__icon {
background: $purple-light; background: $purple-light;
color: $purple; color: $purple;
@@ -130,6 +133,7 @@ $red: #d93025;
&--teal { &--teal {
border-left-color: $teal; border-left-color: $teal;
.stat-card__icon { .stat-card__icon {
background: $teal-light; background: $teal-light;
color: $teal; color: $teal;
@@ -138,6 +142,7 @@ $red: #d93025;
&--orange { &--orange {
border-left-color: $orange; border-left-color: $orange;
.stat-card__icon { .stat-card__icon {
background: $orange-light; background: $orange-light;
color: $orange; color: $orange;
@@ -204,7 +209,7 @@ $red: #d93025;
.table-wrapper { .table-wrapper {
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
overflow: hidden; 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: 1px solid $gray-200;
border-top: none; border-top: none;
@@ -370,7 +375,7 @@ table[mat-table] {
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: rgba(0, 0, 0, 0.5); background: rgb(0 0 0 / 50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -384,7 +389,7 @@ table[mat-table] {
width: 85%; width: 85%;
max-width: 900px; max-width: 900px;
position: relative; 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 { .pdf-modal-close {
@@ -46,19 +46,17 @@
<!-- Card Body --> <!-- Card Body -->
<div class="idt-card__body"> <div class="idt-card__body">
<div class="idt-grid"> <div class="idt-meta">
<div class="idt-field"> <div class="idt-meta__row">
<div class="idt-field__label">{{ 'labels.inputs.Id' | translate }}</div> <i class="fa fa-key idt-meta__icon"></i>
<div class="idt-field__value idt-field__value--mono">{{ identity.id }}</div> <span class="idt-meta__label">{{ 'labels.inputs.Document Key' | translate }}</span>
</div> <span class="idt-meta__value idt-meta__value--mono">{{ identity.documentKey || '—' }}</span>
<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> </div>
@if (identity.description) { @if (identity.description) {
<div class="idt-field"> <div class="idt-meta__row">
<div class="idt-field__label">{{ 'labels.inputs.Description' | translate }}</div> <i class="fa fa-sticky-note-o idt-meta__icon"></i>
<div class="idt-field__value">{{ identity.description }}</div> <span class="idt-meta__label">{{ 'labels.inputs.Description' | translate }}</span>
<span class="idt-meta__value">{{ identity.description }}</span>
</div> </div>
} }
</div> </div>
@@ -68,6 +66,7 @@
<div class="idt-docs"> <div class="idt-docs">
<div class="idt-docs__title"> <div class="idt-docs__title">
<i class="fa fa-paperclip"></i> {{ 'labels.inputs.Identity Documents' | translate }} <i class="fa fa-paperclip"></i> {{ 'labels.inputs.Identity Documents' | translate }}
<span class="idt-docs__count">{{ identity.documents.length }}</span>
</div> </div>
<div class="idt-docs__grid"> <div class="idt-docs__grid">
@for (document of identity.documents; track trackByDocumentId($index, document)) { @for (document of identity.documents; track trackByDocumentId($index, document)) {
@@ -80,13 +79,15 @@
<img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" /> <img [src]="previewThumbnails[document.id]" [alt]="document.name" class="idt-doc__thumb" loading="lazy" />
} @else { } @else {
<div class="idt-doc__placeholder"> <div class="idt-doc__placeholder">
<i class="fa fa-file-o"></i> <i class="fa fa-file-text-o"></i>
</div> </div>
} }
<div class="idt-doc__info"> <div class="idt-doc__info">
<span class="idt-doc__name">{{ document.name }}</span> <span class="idt-doc__name">{{ document.name }}</span>
@if (document.fileName) { @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> </div>
@if (isPreviewable(document)) { @if (isPreviewable(document)) {
@@ -79,190 +79,284 @@ $red-light: #fce8e6;
.idt-card { .idt-card {
background: $white; background: $white;
border: 1px solid $gray-200; border: 1px solid $gray-200;
border-radius: 8px; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin-bottom: 12px; margin-bottom: 14px;
transition: box-shadow 0.15s, border-color 0.15s;
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: $primary;
opacity: 0.7;
}
&:hover {
border-color: $primary-light;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.08);
}
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 18px; padding: 14px 18px 14px 22px;
background: $gray-50; background: linear-gradient(to right, $primary-light 0%, $gray-50 40%, $white 100%);
border-bottom: 1px solid $gray-200; border-bottom: 1px solid $gray-100;
} }
&__title { &__title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
min-width: 0;
flex: 1;
} }
&__type-icon { &__type-icon {
font-size: 14px; font-size: 15px;
color: $primary; color: $white;
width: 28px; width: 34px;
height: 28px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: $primary-light; background: linear-gradient(135deg, $primary, #4285f4);
border-radius: 6px; border-radius: 8px;
box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
flex-shrink: 0;
} }
&__type { &__type {
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: $gray-900; color: $gray-900;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&__status { &__status {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 700;
padding: 2px 9px; padding: 3px 10px;
border-radius: 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; } &::before {
&--inactive { background: $red-light; color: $red; } 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 { &__delete {
width: 30px !important; width: 32px !important;
height: 30px !important; height: 32px !important;
min-width: 30px !important; min-width: 32px !important;
padding: 0 !important; padding: 0 !important;
border-radius: 6px !important; border-radius: 8px !important;
border-color: $gray-200 !important; border-color: $gray-200 !important;
color: $gray-500 !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 { &__body {
padding: 14px 18px; padding: 16px 18px 18px 22px;
} }
} }
// Details grid // Metadata rows (compact, no grid)
.idt-grid { .idt-meta {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-direction: column;
gap: 0; gap: 2px;
border: 1px solid $gray-100; background: $gray-50;
border-radius: 6px; border-radius: 8px;
overflow: hidden; 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 { &:last-child { border-bottom: none; }
padding: 8px 12px; }
border-bottom: 1px solid $gray-100;
border-right: 1px solid $gray-100;
&:nth-child(3n) { border-right: none; } &__icon {
&:hover { background: $gray-50; } 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 { &__label {
font-size: 9px; font-size: 10px;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: $gray-500; color: $gray-500;
margin-bottom: 2px; min-width: 95px;
} }
&__value { &__value {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: $gray-900; color: $gray-900;
word-break: break-word;
flex: 1;
min-width: 0;
&--mono { &--mono {
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 12px; font-size: 12px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
color: $primary;
font-weight: 600;
} }
} }
@media (width <= 600px) {
&__row { flex-wrap: wrap; }
&__label { min-width: 0; }
}
} }
// Documents section // Documents section
.idt-docs { .idt-docs {
margin-top: 12px; margin-top: 4px;
&__title { &__title {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: $gray-500; 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 { &__grid {
display: flex; display: grid;
gap: 8px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
flex-wrap: wrap; gap: 10px;
} }
} }
.idt-doc { .idt-doc {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 8px 12px; padding: 10px 12px;
background: $gray-50; background: $white;
border: 1px solid $gray-100; border: 1px solid $gray-200;
border-radius: 6px; border-radius: 8px;
transition: all 0.12s; transition: all 0.15s;
min-width: 180px; position: relative;
overflow: hidden;
&--clickable { &--clickable {
cursor: pointer; cursor: pointer;
&:hover { &:hover {
border-color: $primary; border-color: $primary;
background: $primary-light; box-shadow: 0 2px 8px rgba(26, 115, 232, 0.1);
transform: translateY(-1px);
} }
} }
&__thumb { &__thumb {
width: 36px; width: 42px;
height: 36px; height: 42px;
border-radius: 4px; border-radius: 6px;
object-fit: cover; object-fit: cover;
border: 1px solid $gray-200; border: 1px solid $gray-200;
flex-shrink: 0;
} }
&__placeholder { &__placeholder {
width: 36px; width: 42px;
height: 36px; height: 42px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: $gray-200; background: linear-gradient(135deg, $primary-light, $gray-100);
border-radius: 4px; border: 1px solid $gray-200;
color: $gray-500; border-radius: 6px;
font-size: 14px; color: $primary;
font-size: 16px;
flex-shrink: 0;
} }
&__info { &__info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
min-width: 0; min-width: 0;
flex: 1;
} }
&__name { &__name {
font-size: 12px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: $gray-900; color: $gray-900;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 150px;
} }
&__file { &__file {
@@ -271,15 +365,27 @@ $red-light: #fce8e6;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 150px; display: inline-flex;
align-items: center;
gap: 4px;
i { font-size: 9px; }
} }
&__preview { &__preview {
font-size: 12px; font-size: 13px;
color: $primary; color: $primary;
margin-left: auto; margin-left: auto;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: $primary-light;
border-radius: 6px;
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
flex-shrink: 0;
} }
&:hover .idt-doc__preview { opacity: 1; } &:hover .idt-doc__preview { opacity: 1; }
@@ -86,6 +86,10 @@ export class IdentitiesTabComponent implements OnDestroy {
clientIdentifierTemplate: any; clientIdentifierTemplate: any;
/** Client Id */ /** Client Id */
clientId: string; clientId: string;
/** Client display name (prefilled into dialog) */
clientName = '';
/** Formatted permanent address (prefilled into dialog) */
clientAddress = '';
/** Identities Columns */ /** Identities Columns */
identitiesColumns: string[] = [ identitiesColumns: string[] = [
'id', 'id',
@@ -126,6 +130,39 @@ export class IdentitiesTabComponent implements OnDestroy {
this.clientIdentifierTemplate = data.clientIdentifierTemplate; this.clientIdentifierTemplate = data.clientIdentifierTemplate;
this.prefetchThumbnails(); 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 { ngOnDestroy(): void {
@@ -143,97 +180,97 @@ export class IdentitiesTabComponent implements OnDestroy {
} }
/** /**
* Add Client Identifier with unified form (identifier + document upload) * Add Client Identifier — opens KYC dialog that may create up to two
* identifiers (identity proof + address proof), each with front/back docs.
*/ */
addIdentifier() { 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, { const dialogRef = this.dialog.open(UploadDocumentDialogComponent, {
width: '700px', width: '880px',
maxWidth: '95vw',
data: { data: {
documentIdentifier: true, documentIdentifier: true,
allowedDocumentTypes: translatedDocTypes, clientName: this.clientName,
statusOptions: statusOptions clientAddress: this.clientAddress
} }
}); });
dialogRef.afterClosed().subscribe((response: any) => { dialogRef.afterClosed().subscribe((response: any) => {
if (response) { if (!response) return;
// Create identifier data const description = response.description || '';
const identifierData = { if (response.id) {
documentTypeId: response.documentTypeId, this.createIdentifierWithDocs(response.id, description, 'id');
status: response.status, }
documentKey: response.documentKey, if (response.address) {
description: response.description 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 const files: { slot: string; file: File }[] = section.files || [];
this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({ if (!files.length) {
next: (res: any) => { this.clientIdentities.push(newIdentity);
const newIdentifierId = res.resourceId; return;
const selectedDocType = this.clientIdentifierTemplate.allowedDocumentTypes.find( }
(doc: any) => doc.id === response.documentTypeId
);
// Create new identity entry let pending = files.length;
const newIdentity: any = { files.forEach((entry) => {
id: newIdentifierId, const formData = new FormData();
description: response.description, formData.append('name', `${section.proof?.name || 'Document'} — ${entry.slot}`);
documentType: selectedDocType, formData.append('file', entry.file);
documentKey: response.documentKey, this.clientService.uploadClientIdentifierDocument(newIdentifierId, formData).subscribe({
documents: [] as any[], next: (docRes: any) => {
clientId: this.clientId, const newDoc = {
status: id: docRes.resourceId,
response.status === 'Active' parentEntityType: 'client_identifiers',
? 'clientIdentifierStatusType.active' parentEntityId: newIdentifierId,
: 'clientIdentifierStatusType.inactive' name: `${section.proof?.name || 'Document'} — ${entry.slot}`,
}; fileName: entry.file.name
};
// If file was uploaded, attach document to the identifier newIdentity.documents.push(newDoc);
if (response.file) { this.setThumbnail(newDoc);
const formData: FormData = new FormData(); if (--pending === 0) {
formData.append('name', response.fileName); this.clientIdentities.push(newIdentity);
formData.append('file', response.file); }
this.clientService.uploadClientIdentifierDocument(newIdentifierId, formData).subscribe({ },
next: (docRes: any) => { error: (err: any) => {
const newDoc = { console.error('Failed to upload document', err);
id: docRes.resourceId, if (--pending === 0) {
parentEntityType: 'client_identifiers', this.clientIdentities.push(newIdentity);
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
} }
}, });
error: (err: any) => {
console.error('Failed to create identifier', err);
}
}); });
},
error: (err: any) => {
console.error('Failed to create identifier', err);
} }
}); });
} }
@@ -245,14 +282,16 @@ export class IdentitiesTabComponent implements OnDestroy {
* @param {number} index Index * @param {number} index Index
*/ */
deleteIdentifier(clientId: string, identifierId: string, index: number) { 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, { 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) => { deleteIdentifierDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) { if (response?.delete) {
this.clientService.deleteClientIdentifier(clientId, identifierId).subscribe((res) => { this.clientService.deleteClientIdentifier(clientId, identifierId).subscribe(() => {
this.clientIdentities.splice(index, 1); this.clientIdentities.splice(index, 1);
// Array mutation triggers @for re-render
}); });
} }
}); });
@@ -27,10 +27,17 @@
</span> </span>
} }
</div> </div>
<button mat-raised-button color="primary" (click)="toggleEditMode()" type="button"> <div class="kyc-header-actions">
<fa-icon [icon]="isEditMode ? 'times' : 'edit'" class="m-r-10"></fa-icon> @if (!isEditMode && kycData) {
{{ isEditMode ? ('labels.buttons.Cancel' | translate) : ('labels.buttons.Edit' | translate) }} <button mat-stroked-button class="kyc-print-btn" type="button" (click)="printSelfDeclaration()">
</button> <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> </div>
<!-- KYC Summary (read-only when not in edit mode) --> <!-- KYC Summary (read-only when not in edit mode) -->
@@ -255,6 +262,100 @@
</div> </div>
} }
</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 --> <!-- No KYC Data State -->
@@ -612,7 +612,7 @@
color: #1e293b; color: #1e293b;
} }
@media (max-width: 960px) { @media (width <= 960px) {
.kv-table__col { .kv-table__col {
&--doc { flex: 0 0 120px; } &--doc { flex: 0 0 120px; }
&--date { display: none; } &--date { display: none; }
@@ -623,9 +623,281 @@
.kv-meta-item:nth-child(3n) { border-right: none; } .kv-meta-item:nth-child(3n) { border-right: none; }
} }
@media (max-width: 640px) { @media (width <= 640px) {
.kv-table__row { flex-wrap: wrap; } .kv-table__row { flex-wrap: wrap; }
.kv-table__col { &--status, &--action { flex: 0 0 50%; } } .kv-table__col { &--status, &--action { flex: 0 0 50%; } }
.kv-meta-grid { grid-template-columns: 1fr 1fr; } .kv-meta-grid { grid-template-columns: 1fr 1fr; }
.kv-otp-bar { flex-wrap: wrap; } .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 */ /** Custom Services */
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -51,6 +52,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
clientId: string; clientId: string;
kycId: string | null = null; kycId: string | null = null;
kycData: any = null; kycData: any = null;
clientData: any = null;
clientDateOfBirth: string | null = null; clientDateOfBirth: string | null = null;
isNonIndividual = false; isNonIndividual = false;
loading = true; loading = true;
@@ -58,6 +60,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
isEditMode = false; isEditMode = false;
errorMessage: string | null = null; 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 } = { verifying: { pan: boolean; aadhaar: boolean; gstin: boolean } = {
pan: false, pan: false,
aadhaar: 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 */ /** Load client's date of birth and legal form from parent route resolved data */
loadClientDateOfBirth(): void { loadClientDateOfBirth(): void {
this.route.parent.data.subscribe((data: any) => { this.route.parent.data.subscribe((data: any) => {
this.clientData = data?.clientViewData || null;
if (data?.clientViewData?.dateOfBirth) { if (data?.clientViewData?.dateOfBirth) {
const dob = data.clientViewData.dateOfBirth; const dob = data.clientViewData.dateOfBirth;
if (Array.isArray(dob) && dob.length >= 3) { if (Array.isArray(dob) && dob.length >= 3) {
@@ -444,6 +454,231 @@ export class KycTabComponent implements OnInit, OnDestroy {
} }
} }
/** Generate Self Declaration PDF with KYC details */
printSelfDeclaration(): void {
const client = this.clientData || {};
const kyc = this.kycData || {};
const clientName = client.displayName || client.fullname || '';
const cifNumber = client.accountNo || client.cifNumber || this.clientId;
const dob = this.clientDateOfBirth ? this.formatDate(client.dateOfBirth) : '—';
const officeName = client.officeName || '';
const today = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const contentWidth = pageWidth - margin * 2;
let y = 20;
// Helper
const addLine = (label: string, value: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(label, margin, yPos);
doc.setFont('helvetica', 'normal');
doc.text(value || '—', margin + 55, yPos);
return yPos + 7;
};
const drawHr = (yPos: number): number => {
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.line(margin, yPos, pageWidth - margin, yPos);
return yPos + 5;
};
// ── Header ──
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('Self Declaration Form', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('KYC / Re-KYC Compliance — As per RBI Master Direction on KYC', pageWidth / 2, 21, { align: 'center' });
doc.setTextColor(0, 0, 0);
y = 40;
// ── Branch / Date ──
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Branch: ${officeName}`, margin, y);
doc.text(`Date: ${today}`, pageWidth - margin, y, { align: 'right' });
y += 8;
y = drawHr(y);
// ── Section: Customer Details ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Customer Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('Customer Name:', clientName, y);
y = addLine('CIF Number:', cifNumber, y);
y = addLine('Date of Birth:', dob, y);
if (client.mobileNo) y = addLine('Mobile:', client.mobileNo, y);
if (client.emailAddress) y = addLine('Email:', client.emailAddress, y);
y += 3;
y = drawHr(y);
// ── Section: KYC Documents ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('KYC Document Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
// PAN
y = addLine('PAN Number:', kyc.panNumber || '—', y);
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
// Aadhaar
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
// PAN-Aadhaar Link
const seedingStatus = kyc.panAadhaarSeedingStatus === 'Y' ? 'Linked' : kyc.panAadhaarSeedingStatus === 'N' ? 'Not Linked' : kyc.panAadhaarSeedingStatus === 'E' ? 'Exempt' : 'Pending';
y = addLine('PAN-Aadhaar Link:', seedingStatus, y);
// GSTIN (Non-Individual)
if (this.isNonIndividual && kyc.gstinNumber) {
y = addLine('GSTIN:', kyc.gstinNumber, y);
y = addLine('GSTIN Status:', kyc.gstinVerified ? 'Verified' : 'Pending', y);
}
// Enterprise PAN / CIN
if (this.isNonIndividual && kyc.enterprisePan) {
y = addLine('Enterprise PAN:', kyc.enterprisePan, y);
}
if (this.isNonIndividual && kyc.cinNumber) {
y = addLine('CIN:', kyc.cinNumber, y);
}
y += 3;
y = drawHr(y);
// ── Section: KYC Summary ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('KYC Summary', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('KYC Type:', kyc.kycType === 1 ? 'Individual' : kyc.kycType === 2 ? 'Non-Individual' : '—', y);
y = addLine('Risk Category:', kyc.riskCategory === 1 ? 'Low' : kyc.riskCategory === 2 ? 'Medium' : kyc.riskCategory === 3 ? 'High' : '—', y);
y = addLine('Initiated On:', this.formatDate(kyc.initiatedOn), y);
y = addLine('Last Verified:', this.formatDate(kyc.verifiedOn), y);
y = addLine('Expiry Date:', this.formatDate(kyc.expiryDate), y);
y += 3;
y = drawHr(y);
// ── Declaration Text ──
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Declaration', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.5);
const declarationText = `I, ${clientName || '_______________'}, hereby declare that the information provided above is true, correct and complete to the best of my knowledge and belief. I understand that furnishing false information is punishable under applicable laws. I authorize the Bank to verify the above details through appropriate channels as per RBI Master Direction on KYC (DBR.AML.BC.No.81/14.01.001/2015-16) and its subsequent amendments.
I further confirm that:
1. I am not a Politically Exposed Person (PEP) / relative of a PEP (unless declared otherwise).
2. The source of funds in my account is from legitimate sources.
3. I shall promptly notify the Bank of any change in the above information.
4. I have read and understood the Bank's terms and conditions regarding KYC compliance.`;
const lines = doc.splitTextToSize(declarationText, contentWidth);
doc.text(lines, margin, y);
y += lines.length * 4.5 + 10;
// ── Signature section ──
y = drawHr(y);
y += 5;
// Left: Client signature
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('________________________', margin, y + 15);
doc.text('Signature of Customer', margin, y + 21);
doc.text(`Name: ${clientName}`, margin, y + 27);
doc.text(`Date: _______________`, margin, y + 33);
doc.text(`Place: _______________`, margin, y + 39);
// Right: Bank official
doc.text('________________________', pageWidth - margin - 50, y + 15);
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
doc.text('Stamp & Seal', pageWidth - margin - 50, y + 39);
// ── Footer ──
const footerY = doc.internal.pageSize.getHeight() - 10;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text('This is a system-generated document. Generated from CBS Nexora — KYC Module.', pageWidth / 2, footerY, { align: 'center' });
// Save
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
}
/** Handle file selection for signed declaration upload */
onDeclarationFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files?.length) {
this.selectedDeclarationFile = input.files[0];
this.reKycError = null;
}
}
/** Upload signed declaration and complete re-KYC */
completeReKyc(): void {
if (!this.selectedDeclarationFile || !this.clientId || !this.kycId) return;
this.reKycCompleting = true;
this.reKycError = null;
this.reKycSuccess = false;
// Step 1: Upload the signed declaration as a client document
const formData = new FormData();
formData.append('file', this.selectedDeclarationFile);
formData.append('name', 'KYC Self Declaration');
formData.append('description', `Signed self-declaration form uploaded on ${new Date().toLocaleDateString('en-IN')}`);
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: () => {
// Step 2: Call re-KYC completion API to update verified/expiry dates
this.clientsService.completeReKyc(this.clientId, this.kycId!).subscribe({
next: () => {
this.reKycCompleting = false;
this.reKycSuccess = true;
this.selectedDeclarationFile = null;
this.loadKycData(); // Refresh to show updated dates
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to complete re-KYC. Please try again.';
}
});
},
error: (err: any) => {
this.reKycCompleting = false;
this.reKycError = err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Failed to upload declaration. Please try again.';
}
});
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
@@ -16,7 +16,7 @@ $primary-light: #e8f0fe;
&__left { display: flex; align-items: center; gap: 10px; } &__left { display: flex; align-items: center; gap: 10px; }
&__icon { color: #60a5fa; } &__icon { color: #60a5fa; }
&__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; } &__title { font-size: 15px; font-weight: 600; color: #fff; margin: 0; }
&__sub { font-size: 10px; color: rgba(255,255,255,0.45); margin: 1px 0 0; } &__sub { font-size: 10px; color: rgb(255 255 255 / 45%); margin: 1px 0 0; }
} }
.nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b; .nom-loading { display: flex; align-items: center; gap: 8px; padding: 24px 0; justify-content: center; font-size: 13px; color: #64748b;
@@ -36,12 +36,14 @@ $primary-light: #e8f0fe;
&__right { display: flex; align-items: center; gap: 10px; } &__right { display: flex; align-items: center; gap: 10px; }
} }
.nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; } .nom-type-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.nom-type-chip { .nom-type-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff; border: 1.5px solid #e2e8f0; border-radius: 6px; background: #fff;
font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s; font-size: 12px; font-weight: 500; color: #64748b; cursor: pointer; transition: all 0.15s;
mat-icon { font-size: 16px; width: 16px; height: 16px; } 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; } &__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; &.active { border-color: $primary; background: $primary-light; color: $primary;
.nom-type-chip__count { background: $primary; color: #fff; } .nom-type-chip__count { background: $primary; color: #fff; }
} }
@@ -56,6 +58,7 @@ $primary-light: #e8f0fe;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px;
&__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; } &__label { font-size: 11px; font-weight: 600; color: #64748b; white-space: nowrap; }
&__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } &__track { flex: 1; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; }
&__fill { &__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); background: linear-gradient(90deg, #3b82f6, #10b981);
@@ -67,12 +70,14 @@ $primary-light: #e8f0fe;
/* Nominee card — compact */ /* Nominee card — compact */
.nom-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 6px; overflow: hidden; } .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__main { display: flex; align-items: center; gap: 10px; padding: 8px 12px; }
.nom-card__avatar { .nom-card__avatar {
width: 32px; height: 32px; border-radius: 50%; background: #dbeafe; width: 32px; height: 32px; border-radius: 50%; background: #dbeafe;
display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #1565c0; flex-shrink: 0;
mat-icon { font-size: 18px; } mat-icon { font-size: 18px; }
} }
.nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } .nom-card__photo { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.nom-card__sig { .nom-card__sig {
flex-shrink: 0; 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; }
@@ -80,6 +85,7 @@ $primary-light: #e8f0fe;
.nom-card__info { flex: 1; min-width: 0; } .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__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__meta { font-size: 11px; color: #64748b; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nom-card__id { .nom-card__id {
font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff; font-size: 10px; font-weight: 600; color: #1d4ed8; background: #eff6ff;
padding: 2px 8px; border-radius: 4px; white-space: nowrap; padding: 2px 8px; border-radius: 4px; white-space: nowrap;
@@ -87,12 +93,15 @@ $primary-light: #e8f0fe;
} }
.nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; } .nom-card__share { text-align: center; min-width: 40px; flex-shrink: 0; }
.nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; } .nom-card__share-value { font-size: 15px; font-weight: 700; color: #1565c0; }
.nom-card__actions { display: flex; gap: 0; flex-shrink: 0; .nom-card__actions { display: flex; gap: 0; flex-shrink: 0;
button { width: 32px !important; height: 32px !important; line-height: 32px !important; 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__delete { color: #94a3b8 !important; &:hover { color: #ef4444 !important; } }
.nom-card__guardian { .nom-card__guardian {
display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed; display: flex; align-items: center; gap: 5px; padding: 5px 12px; background: #fff7ed;
font-size: 11px; color: #92400e; border-top: 1px solid #fde68a; font-size: 11px; color: #92400e; border-top: 1px solid #fde68a;
@@ -122,13 +131,15 @@ $primary-light: #e8f0fe;
.nom-search-wrapper { position: relative; margin-bottom: 8px; } .nom-search-wrapper { position: relative; margin-bottom: 8px; }
.nom-search-field { width: 100%; } .nom-search-field { width: 100%; }
.nom-search-prefix { color: #94a3b8; } .nom-search-prefix { color: #94a3b8; }
.nom-search-results { .nom-search-results {
position: absolute; top: 56px; left: 0; right: 0; z-index: 100; position: absolute; top: 56px; left: 0; right: 0; z-index: 100;
background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px;
box-shadow: 0 6px 20px rgba(0,0,0,0.1); max-height: 220px; overflow-y: auto; box-shadow: 0 6px 20px rgb(0 0 0 / 10%); max-height: 220px; overflow-y: auto;
&--inline { position: relative; top: 0; margin-top: 4px; } &--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 { .nom-search-item {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f1f5f9; 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; } &:last-child { border-bottom: none; } &:hover { background: #f0f7ff; }
@@ -149,6 +160,7 @@ $primary-light: #e8f0fe;
&__info { flex: 1; display: flex; flex-direction: column; } &__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 13px; font-weight: 600; color: #1e293b; } &__name { font-size: 13px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; } &__meta { font-size: 11px; color: #64748b; font-family: 'Roboto Mono', monospace; }
&__sig { &__sig {
flex-shrink: 0; 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; }
@@ -174,10 +186,11 @@ $primary-light: #e8f0fe;
/* Guardian */ /* Guardian */
.nom-guardian-section { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } .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-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; .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; } } button { width: 24px !important; height: 24px !important; line-height: 24px !important; mat-icon { font-size: 14px; } }
} }
/* Zoom */ /* Zoom */
.zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } } .zoomable { cursor: zoom-in; transition: transform 0.15s; &:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgb(0 0 0 / 15%); } }
::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } } ::ng-deep .image-zoom-panel { .mat-mdc-dialog-container { padding: 0 !important; border-radius: 8px !important; overflow: hidden; } }
@@ -83,15 +83,15 @@ $teal: #00897b;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 0; gap: 0;
@media (max-width: 1024px) { @media (width <= 1024px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@media (max-width: 480px) { @media (width <= 480px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -192,7 +192,7 @@ $teal: #00897b;
} }
&.monospace { &.monospace {
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px; font-size: 13px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
+59 -22
View File
@@ -9,28 +9,14 @@
<mat-card class="container"> <mat-card class="container">
<!-- Header Bar --> <!-- Header Bar -->
<div class="table-header"> <div class="table-header">
<div class="table-title-group"> <div class="table-header-row">
<h2 class="table-title"> <div class="table-title-group">
<div class="t-logomark"><span></span><span></span><span></span><span></span></div> <h2 class="table-title">
{{ 'labels.menus.Clients' | translate }} <div class="t-logomark"><span></span><span></span><span></span><span></span></div>
</h2> {{ 'labels.menus.Clients' | translate }}
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span> </h2>
</div> <span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
</div>
<div class="table-actions">
<mat-form-field class="table-search" appearance="outline">
<input
matInput
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
(keydown.enter)="search($event.target.value)"
/>
</mat-form-field>
@if (existsClientsToFilter) {
<mat-checkbox #showClosedAccounts labelPosition="after" class="table-checkbox">
{{ 'labels.inputs.Show Closed Accounts' | translate }}
</mat-checkbox>
}
<div class="table-buttons"> <div class="table-buttons">
<button <button
@@ -54,6 +40,42 @@
</button> </button>
</div> </div>
</div> </div>
<div class="table-filters">
<mat-form-field class="table-search" appearance="outline">
<input
matInput
placeholder="{{ 'labels.text.SearchByClient' | translate }}"
(keydown.enter)="search($event.target.value)"
/>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option>
<mat-option value="pending">Pending</mat-option>
<mat-option value="closed">Closed</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="legalFormFilter" (selectionChange)="applyClientFilters()" placeholder="Legal Form">
<mat-option value="">All Forms</mat-option>
<mat-option value="1">Individual</mat-option>
<mat-option value="2">Non-Individual</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="ckycFilter" (selectionChange)="applyClientFilters()" placeholder="CKYC">
<mat-option value="">All CKYC</mat-option>
<mat-option value="done">CKYC Done</mat-option>
<mat-option value="pending">CKYC Pending</mat-option>
</mat-select>
</mat-form-field>
</div>
</div> </div>
<!-- Table --> <!-- Table -->
@@ -69,6 +91,9 @@
<td mat-cell *matCellDef="let row" class="cell-link"> <td mat-cell *matCellDef="let row" class="cell-link">
<mat-icon class="row-legal-icon" [ngClass]="row.legalForm?.id === 2 ? 'entity' : 'person'">{{ row.legalForm?.id === 2 ? 'business' : 'person' }}</mat-icon> <mat-icon class="row-legal-icon" [ngClass]="row.legalForm?.id === 2 ? 'entity' : 'person'">{{ row.legalForm?.id === 2 ? 'business' : 'person' }}</mat-icon>
{{ hideClientData ? maskName(row.displayName) : row.displayName }} {{ hideClientData ? maskName(row.displayName) : row.displayName }}
@if (row._ckycDone) {
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
}
</td> </td>
</ng-container> </ng-container>
@@ -86,6 +111,18 @@
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.cifNumber }}</td> <td mat-cell *matCellDef="let row" class="cell-mono">{{ row.cifNumber }}</td>
</ng-container> </ng-container>
<!-- CKYC Number Column -->
<ng-container matColumnDef="ckycNumber">
<th mat-header-cell *matHeaderCellDef>CKYC Number</th>
<td mat-cell *matCellDef="let row" class="cell-mono">
@if (row._ckycNumber) {
{{ row._ckycNumber }}
} @else {
<span class="ckyc-pending">—</span>
}
</td>
</ng-container>
<!-- Legal Form Column --> <!-- Legal Form Column -->
<ng-container matColumnDef="legalForm"> <ng-container matColumnDef="legalForm">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Legal Form' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Legal Form' | translate }}</th>
+42 -9
View File
@@ -8,7 +8,6 @@
/** Angular Imports. */ /** Angular Imports. */
import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort, MatSortHeader } from '@angular/material/sort'; import { MatSort, Sort, MatSortHeader } from '@angular/material/sort';
import { import {
@@ -46,7 +45,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./clients.component.scss'], styleUrls: ['./clients.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
FaIconComponent, FaIconComponent,
MatProgressBar, MatProgressBar,
MatTable, MatTable,
@@ -88,12 +86,11 @@ export class ClientsComponent implements OnInit {
.join(' '); .join(' ');
} }
@ViewChild('showClosedAccounts') showClosedAccounts: MatCheckbox;
displayedColumns = [ displayedColumns = [
'displayName', 'displayName',
'accountNumber', 'accountNumber',
'cifNumber', 'cifNumber',
'ckycNumber',
'legalForm', 'legalForm',
'status', 'status',
'profileCompletion', 'profileCompletion',
@@ -115,6 +112,12 @@ export class ClientsComponent implements OnInit {
sortAttribute = ''; sortAttribute = '';
sortDirection = ''; sortDirection = '';
statusFilter = '';
legalFormFilter = '';
ckycFilter = '';
private allRows: any[] = [];
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
@@ -139,14 +142,16 @@ export class ClientsComponent implements OnInit {
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection) .searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
.subscribe( .subscribe(
(data: any) => { (data: any) => {
this.dataSource.data = data.content; this.allRows = data.content || [];
this.totalRows = data.totalElements; this.totalRows = data.totalElements;
this.existsClientsToFilter = data.numberOfElements > 0; this.existsClientsToFilter = data.numberOfElements > 0;
this.notExistsClientsToFilter = !this.existsClientsToFilter; this.notExistsClientsToFilter = !this.existsClientsToFilter;
this.isLoading = false; this.isLoading = false;
this.applyClientFilters();
// Fetch KYC & address status for visible rows // Fetch KYC & address status for visible rows
this.loadProfileStatus(data.content); this.loadProfileStatus(this.allRows);
}, },
(error: any) => { (error: any) => {
this.isLoading = false; this.isLoading = false;
@@ -167,7 +172,7 @@ export class ClientsComponent implements OnInit {
).subscribe((data: any) => { ).subscribe((data: any) => {
if (data?.legalForm) { if (data?.legalForm) {
clients[i].legalForm = data.legalForm; clients[i].legalForm = data.legalForm;
this.dataSource.data = [...clients]; this.applyClientFilters();
} }
}); });
@@ -176,7 +181,9 @@ export class ClientsComponent implements OnInit {
).subscribe((kyc: any) => { ).subscribe((kyc: any) => {
if (kyc) { if (kyc) {
clients[i]._kycDone = kyc.kycStatus === 300; clients[i]._kycDone = kyc.kycStatus === 300;
this.dataSource.data = [...clients]; clients[i]._ckycDone = !!kyc.ckycNumber;
clients[i]._ckycNumber = kyc.ckycNumber || '';
this.applyClientFilters();
} }
}); });
@@ -185,12 +192,38 @@ export class ClientsComponent implements OnInit {
).subscribe((addr: any) => { ).subscribe((addr: any) => {
if (addr && Array.isArray(addr) && addr.length > 0) { if (addr && Array.isArray(addr) && addr.length > 0) {
clients[i]._addressDone = true; clients[i]._addressDone = true;
this.dataSource.data = [...clients]; this.applyClientFilters();
} }
}); });
}); });
} }
applyClientFilters() {
const status = this.statusFilter;
const legal = this.legalFormFilter;
const ckyc = this.ckycFilter;
const filtered = (this.allRows || []).filter((row) => {
const code = (row?.status?.code || '').toLowerCase();
if (status && !code.includes(status)) {
return false;
}
if (legal && String(row?.legalForm?.id ?? '') !== legal) {
return false;
}
if (ckyc === 'done' && !row._ckycDone) {
return false;
}
if (ckyc === 'pending' && row._ckycDone) {
return false;
}
return true;
});
this.dataSource.data = filtered;
}
pageChanged(event: PageEvent) { pageChanged(event: PageEvent) {
this.pageSize = event.pageSize; this.pageSize = event.pageSize;
this.currentPage = event.pageIndex; this.currentPage = event.pageIndex;
+97
View File
@@ -320,6 +320,80 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/identifiers`); return this.http.get(`/clients/${clientId}/identifiers`);
} }
getKycProofTypes(type?: 'id' | 'address'): Observable<any> {
const options = type ? { params: new HttpParams().set('type', type) } : {};
return this.http.get(`/kyc/proof-types`, options);
}
/**
* Phase 1 – Record Master: fetch clients missing CKYC registration
* or artefacts. Backed by GET /ckyc/record-master.
*/
getCkycRecordMaster(filters: {
officeId?: number | string;
productId?: number | string;
fromDate?: string;
toDate?: string;
missing?: 'photo' | 'signature' | 'id' | 'address' | 'any';
limit?: number;
offset?: number;
} = {}): Observable<any> {
let params = new HttpParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
}
});
return this.http.get(`/ckyc/record-master`, { params });
}
/**
* Phase 2 – Auto ZIP Creation: bundle selected clients into a CERSAI
* ZIP on the server. Backed by POST /ckyc/batches.
*/
createCkycBatch(body: { clientIds: (number | string)[]; remarks?: string }): Observable<any> {
return this.http.post(`/ckyc/batches`, body);
}
/**
* Phase 2/3 – List CKYC batches. Supports filters and an optional
* groupByDate=true to bucket results by created_on for the UI.
*/
getCkycBatches(filters: {
fromDate?: string;
toDate?: string;
uploadedToCersai?: boolean;
groupByDate?: boolean;
} = {}): Observable<any> {
let params = new HttpParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params = params.set(key, String(value));
}
});
return this.http.get(`/ckyc/batches`, { params });
}
/** Fetch a single CKYC batch with parsed manifest. */
getCkycBatchById(id: number | string): Observable<any> {
return this.http.get(`/ckyc/batches/${id}`);
}
/** Download the ZIP attached to a batch (via generic document endpoint). */
downloadCkycBatchZip(parentEntityId: number | string, documentId: number | string): Observable<Blob> {
return this.http.get(`/ckyc_batch/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob'
});
}
/**
* Phase 3 – SFTP upload to CERSAI. Uploads the batch ZIP over the
* SFTP channel and returns the ACK + per-client CKYC numbers.
*/
uploadCkycBatchToSftp(batchId: number | string): Observable<any> {
return this.http.post(`/ckyc/batches/${batchId}/sftp-upload`, {});
}
getClientIdentifierTemplate(clientId: string) { getClientIdentifierTemplate(clientId: string) {
return this.http.get(`/clients/${clientId}/identifiers/template`); return this.http.get(`/clients/${clientId}/identifiers/template`);
} }
@@ -653,6 +727,29 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {}); return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
} }
/**
* Verifies DIN (Director Identification Number) for an authorized signatory.
* Sends signatory clientId, DIN number and the non-individual entity name.
* Backend saves DIN & verification status in KYC table and returns company list.
* @param clientId Signatory's Client ID
* @param kycId KYC record ID
* @param dinNumber DIN number to verify
* @param entityName Non-individual entity name (for company match)
*/
verifyDIN(clientId: string, kycId: string, dinNumber: string, entityName: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-din`, { dinNumber, entityName });
}
/**
* Completes Re-KYC after uploading signed self-declaration form.
* Updates Last Verified date to today and sets new Expiry Date (today + 10 years for low risk).
* @param clientId Client ID
* @param kycId KYC record ID
*/
completeReKyc(clientId: string, kycId: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=complete-rekyc`, {});
}
// ═══ Entity Classification APIs ═══ // ═══ Entity Classification APIs ═══
getEntityClassifications(clientId: string): Observable<any> { getEntityClassifications(clientId: string): Observable<any> {
@@ -48,11 +48,11 @@
.client-tabs { .client-tabs {
background: var(--md-sys-color-surface, #fff); background: var(--md-sys-color-surface, #fff);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep { ::ng-deep {
.mat-mdc-tab-header { .mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: 1px solid rgb(0 0 0 / 8%);
} }
.mat-mdc-tab { .mat-mdc-tab {
@@ -96,11 +96,11 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 32px 16px; padding: 32px 16px;
color: rgba(0, 0, 0, 0.54); color: rgb(0 0 0 / 54%);
font-size: 14px; font-size: 14px;
mat-icon { mat-icon {
color: rgba(0, 0, 0, 0.38); color: rgb(0 0 0 / 38%);
} }
} }
@@ -110,7 +110,7 @@
align-items: center; align-items: center;
margin-top: 20px; margin-top: 20px;
padding: 16px 0; padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgb(0 0 0 / 8%);
} }
.action-bar-right { .action-bar-right {
@@ -119,7 +119,7 @@
} }
.cancel-btn { .cancel-btn {
color: rgba(0, 0, 0, 0.6); color: rgb(0 0 0 / 60%);
} }
/* ─── Read-only tab state ─── */ /* ─── Read-only tab state ─── */
@@ -196,8 +196,9 @@
::ng-deep .mdc-linear-progress__bar-inner { ::ng-deep .mdc-linear-progress__bar-inner {
border-color: #fff !important; border-color: #fff !important;
} }
::ng-deep .mdc-linear-progress__buffer-bar { ::ng-deep .mdc-linear-progress__buffer-bar {
background-color: rgba(255, 255, 255, 0.3) !important; background-color: rgb(255 255 255 / 30%) !important;
} }
} }
@@ -65,6 +65,18 @@
</mat-icon> </mat-icon>
<a matLine>{{ 'labels.menus.CKYC' | translate }}</a> <a matLine>{{ 'labels.menus.CKYC' | translate }}</a>
</mat-list-item> </mat-list-item>
<mat-list-item
[routerLink]="['/kyc-management']"
[matTooltipPosition]="tooltipPosition"
matTooltip="KYC Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>
<fa-icon icon="user-shield" size="sm"></fa-icon>
</mat-icon>
<a matLine>KYC Management</a>
</mat-list-item>
<mat-list-item <mat-list-item
[routerLink]="['/bank-calendar']" [routerLink]="['/bank-calendar']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -328,8 +328,7 @@ $hover-bg: #f1f5f9;
.app-sidenav { .app-sidenav {
.icon-menu { .icon-menu {
flex-direction: row; flex-flow: row wrap;
flex-wrap: wrap;
padding: 6px 10px; padding: 6px 10px;
} }
} }
@@ -24,10 +24,7 @@ $top-h: 32px;
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient( background: repeating-linear-gradient(
108deg, transparent 0, transparent 80px, 108deg, transparent 0, transparent 80px,
rgb(255 255 255 / 2.2%) 80px, rgb(255 255 255 / 2.2%) 160px rgb(255 255 255 / 2.2%) 80px, rgb(255 255 255 / 2.2%) 160px
@@ -355,7 +352,7 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
.subledger-link { .subledger-link {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px 6px; padding: 6px;
text-decoration: none; text-decoration: none;
color: #344054; color: #344054;
font-size: 13px; font-size: 13px;
@@ -562,6 +562,7 @@ $slr-b: #bae6fd;
.ft-demand { border-color: $demand-b; background: $demand-bg; .formula-term-label, .formula-term-val { color: $demand; } } .ft-demand { border-color: $demand-b; background: $demand-bg; .formula-term-label, .formula-term-val { color: $demand; } }
.ft-time { border-color: $time-b; background: $time-bg; .formula-term-label, .formula-term-val { color: $time; } } .ft-time { border-color: $time-b; background: $time-bg; .formula-term-label, .formula-term-val { color: $time; } }
.ft-asset { border-color: $asset-b; background: $asset-bg; .formula-term-label, .formula-term-val { color: $asset; } } .ft-asset { border-color: $asset-b; background: $asset-bg; .formula-term-label, .formula-term-val { color: $asset; } }
.ft-ndtl { .ft-ndtl {
border-color: #a78bfa; border-color: #a78bfa;
background: linear-gradient(135deg, #f5f3ff, #f0fdfa); background: linear-gradient(135deg, #f5f3ff, #f0fdfa);
@@ -201,20 +201,45 @@
</div> </div>
</div> </div>
<!-- Decay & TSWM --> <!-- Credit Information Companies — DQR -->
<div class="dash-card"> <div class="dash-card cic-card">
<div class="card-top"> <div class="card-top">
<div class="card-icon-wrap purple"><fa-icon icon="chart-bar"></fa-icon></div> <div class="card-icon-wrap purple"><fa-icon icon="file-alt"></fa-icon></div>
<div class="card-label">Portfolio Quality</div> <div class="card-label">Credit Information Companies</div>
</div> </div>
<div class="cic-subtitle">Data Quality Report (DQR) — as per RBI Guidelines</div>
<div class="quality-grid"> <div class="quality-grid">
<div class="quality-item"> <div class="quality-item">
<div class="quality-label">Decay Analysis</div> <img src="assets/images/cic-cibil.svg" alt="CIBIL" class="cic-logo" />
<div class="quality-badge moderate">Moderate</div> <div class="quality-label">
<span class="cic-name">TransUnion CIBIL</span>
<span class="cic-full">Credit Information Bureau India Limited</span>
</div>
<div class="dqr-percent good">98.2%</div>
</div> </div>
<div class="quality-item"> <div class="quality-item">
<div class="quality-label">TSWM</div> <img src="assets/images/cic-experian.svg" alt="Experian" class="cic-logo" />
<div class="quality-badge active">Active</div> <div class="quality-label">
<span class="cic-name">Experian</span>
<span class="cic-full">Experian Credit Information Company of India</span>
</div>
<div class="dqr-percent good">97.5%</div>
</div>
<div class="quality-item">
<img src="assets/images/cic-equifax.svg" alt="Equifax" class="cic-logo" />
<div class="quality-label">
<span class="cic-name">Equifax</span>
<span class="cic-full">Equifax Credit Information Services Pvt. Ltd.</span>
</div>
<div class="dqr-percent moderate">94.8%</div>
</div>
<div class="quality-item">
<img src="assets/images/cic-crif.svg" alt="CRIF High Mark" class="cic-logo" />
<div class="quality-label">
<span class="cic-name">CRIF High Mark</span>
<span class="cic-full">CRIF High Mark Credit Information Services</span>
</div>
<div class="dqr-percent good">96.1%</div>
</div> </div>
</div> </div>
</div> </div>
+104 -13
View File
@@ -45,6 +45,7 @@ $purple: #7c3aed;
.search-row { .search-row {
width: 100%; width: 100%;
margin: 0; margin: 0;
mat-card-content { mat-card-content {
width: 100%; width: 100%;
} }
@@ -63,7 +64,7 @@ $purple: #7c3aed;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin: 6px 0 6px; margin: 6px 0;
padding: 5px 12px; padding: 5px 12px;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
@@ -78,18 +79,22 @@ $purple: #7c3aed;
background: #e3f2fd; background: #e3f2fd;
color: #0d47a1; color: #0d47a1;
} }
&.financial { &.financial {
background: #e8f5e9; background: #e8f5e9;
color: #1b5e20; color: #1b5e20;
} }
&.deposits { &.deposits {
background: #f3e5f5; background: #f3e5f5;
color: #6a1b9a; color: #6a1b9a;
} }
&.risk { &.risk {
background: #fce4ec; background: #fce4ec;
color: #b71c1c; color: #b71c1c;
} }
&.alerts { &.alerts {
background: #fff3e0; background: #fff3e0;
color: #e65100; color: #e65100;
@@ -105,12 +110,15 @@ $purple: #7c3aed;
.four-col { .four-col {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
.five-col { .five-col {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
} }
.three-col { .three-col {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.two-col { .two-col {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -153,15 +161,19 @@ $purple: #7c3aed;
&.blue { &.blue {
background: $blue; background: $blue;
} }
&.green { &.green {
background: $green; background: $green;
} }
&.orange { &.orange {
background: $orange; background: $orange;
} }
&.red { &.red {
background: $red; background: $red;
} }
&.purple { &.purple {
background: $purple; background: $purple;
} }
@@ -194,10 +206,12 @@ $purple: #7c3aed;
background: #ecfdf5; background: #ecfdf5;
color: #065f46; color: #065f46;
} }
&.warning { &.warning {
background: #fffbeb; background: #fffbeb;
color: #92400e; color: #92400e;
} }
&.info { &.info {
background: #eff6ff; background: #eff6ff;
color: #1e40af; color: #1e40af;
@@ -268,6 +282,7 @@ $purple: #7c3aed;
font-size: 12px; font-size: 12px;
color: #94a3b8; color: #94a3b8;
} }
.metric-value { .metric-value {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
@@ -339,6 +354,7 @@ $purple: #7c3aed;
background: #ecfdf5; background: #ecfdf5;
color: #065f46; color: #065f46;
} }
&.exceed { &.exceed {
background: #fef2f2; background: #fef2f2;
color: #991b1b; color: #991b1b;
@@ -365,15 +381,19 @@ $purple: #7c3aed;
&.blue { &.blue {
background: $blue; background: $blue;
} }
&.green { &.green {
background: $green; background: $green;
} }
&.purple { &.purple {
background: $purple; background: $purple;
} }
&.orange { &.orange {
background: $orange; background: $orange;
} }
&.red { &.red {
background: $red; background: $red;
} }
@@ -441,9 +461,11 @@ $purple: #7c3aed;
.compact-icon { .compact-icon {
font-size: 14px; font-size: 14px;
&.purple-text { &.purple-text {
color: $purple; color: $purple;
} }
&.orange-text { &.orange-text {
color: $orange; color: $orange;
} }
@@ -469,13 +491,16 @@ $purple: #7c3aed;
color: #64748b; color: #64748b;
margin-bottom: 2px; margin-bottom: 2px;
} }
.da-value { .da-value {
font-size: 20px; font-size: 20px;
font-weight: 800; font-weight: 800;
margin-bottom: 6px; margin-bottom: 6px;
&.deposit { &.deposit {
color: $blue; color: $blue;
} }
&.advance { &.advance {
color: $purple; color: $purple;
} }
@@ -497,6 +522,7 @@ $purple: #7c3aed;
&.deposit-fill { &.deposit-fill {
background: linear-gradient(90deg, $blue, #42a5f5); background: linear-gradient(90deg, $blue, #42a5f5);
} }
&.advance-fill { &.advance-fill {
background: linear-gradient(90deg, $purple, #a78bfa); background: linear-gradient(90deg, $purple, #a78bfa);
} }
@@ -532,9 +558,11 @@ $purple: #7c3aed;
.psl-bar { .psl-bar {
height: 100%; height: 100%;
border-radius: 5px; border-radius: 5px;
&.weaker { &.weaker {
background: $orange; background: $orange;
} }
&.msme { &.msme {
background: $green; background: $green;
} }
@@ -548,38 +576,94 @@ $purple: #7c3aed;
text-align: right; text-align: right;
} }
/* ─── Portfolio Quality ─── */ /* ─── Credit Information Companies — DQR ─── */
.cic-card {
.card-top {
margin-bottom: 0;
}
}
.cic-subtitle {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.3px;
margin: 2px 0 10px;
}
.quality-grid { .quality-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 0;
margin-top: 8px; margin-top: 0;
} }
.quality-item { .quality-item {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
.quality-label {
flex: 1;
min-width: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.cic-logo {
width: 34px;
height: 34px;
border-radius: 7px;
flex-shrink: 0;
object-fit: contain;
} }
.quality-label { .quality-label {
font-size: 13px; display: flex;
color: #475569; flex-direction: column;
gap: 1px;
} }
.quality-badge { .cic-name {
font-size: 12px; font-size: 13px;
font-weight: 700; font-weight: 600;
color: #1e293b;
}
.cic-full {
font-size: 10px;
color: #94a3b8;
line-height: 1.3;
}
.dqr-percent {
font-size: 14px;
font-weight: 800;
padding: 4px 12px; padding: 4px 12px;
border-radius: 20px; border-radius: 20px;
flex-shrink: 0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.3px;
&.good {
background: #ecfdf5;
color: #065f46;
}
&.moderate { &.moderate {
background: #fffbeb; background: #fffbeb;
color: #92400e; color: #92400e;
} }
&.active {
background: #ecfdf5; &.low {
color: #065f46; background: #fef2f2;
color: #991b1b;
} }
} }
@@ -629,10 +713,12 @@ $purple: #7c3aed;
&.success { &.success {
color: #065f46; color: #065f46;
} }
&.warning { &.warning {
color: #b45309; color: #b45309;
} }
} }
.npa-note { .npa-note {
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #94a3b8;
@@ -664,9 +750,11 @@ $purple: #7c3aed;
&.green { &.green {
background: $green; background: $green;
} }
&.yellow { &.yellow {
background: $orange; background: $orange;
} }
&.red { &.red {
background: $red; background: $red;
} }
@@ -715,9 +803,11 @@ $purple: #7c3aed;
.four-col { .four-col {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.five-col { .five-col {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.three-col { .three-col {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@@ -733,6 +823,7 @@ $purple: #7c3aed;
.card-big-value { .card-big-value {
font-size: 26px; font-size: 26px;
} }
.mini-value { .mini-value {
font-size: 18px; font-size: 18px;
} }
+21 -21
View File
@@ -31,7 +31,7 @@ $accent: #0ea5e9;
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: 0; right: 0; bottom: 0; inset: 0;
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px); 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; pointer-events: none;
} }
@@ -47,7 +47,7 @@ $accent: #0ea5e9;
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
color: rgba(255, 255, 255, 0.9); color: rgb(255 255 255 / 90%);
strong { strong {
font-weight: 700; font-weight: 700;
@@ -59,7 +59,7 @@ $accent: #0ea5e9;
.welcome-sub { .welcome-sub {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
} }
.welcome-right { .welcome-right {
@@ -72,21 +72,21 @@ $accent: #0ea5e9;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
background: rgba(255, 255, 255, 0.1); background: rgb(255 255 255 / 10%);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgb(255 255 255 / 15%);
border-radius: 6px; border-radius: 6px;
padding: 6px 14px; padding: 6px 14px;
min-width: 280px; min-width: 280px;
transition: background 0.15s; transition: background 0.15s;
&:focus-within { &:focus-within {
background: rgba(255, 255, 255, 0.18); background: rgb(255 255 255 / 18%);
border-color: rgba(255, 255, 255, 0.3); border-color: rgb(255 255 255 / 30%);
} }
} }
.search-icon { .search-icon {
color: rgba(255, 255, 255, 0.5); color: rgb(255 255 255 / 50%);
font-size: 13px; font-size: 13px;
} }
@@ -99,7 +99,7 @@ $accent: #0ea5e9;
width: 100%; width: 100%;
&::placeholder { &::placeholder {
color: rgba(255, 255, 255, 0.45); color: rgb(255 255 255 / 45%);
} }
} }
@@ -115,16 +115,16 @@ $accent: #0ea5e9;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 14px 14px; padding: 14px;
background: #fff; background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(0 0 0 / 6%);
border-radius: 8px; border-radius: 8px;
text-decoration: none; text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s; transition: box-shadow 0.15s, transform 0.15s;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
transform: translateY(-1px); transform: translateY(-1px);
} }
} }
@@ -175,7 +175,7 @@ $accent: #0ea5e9;
.info-card { .info-card {
background: #fff; background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(0 0 0 / 6%);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
@@ -186,7 +186,7 @@ $accent: #0ea5e9;
gap: 8px; gap: 8px;
padding: 10px 16px; padding: 10px 16px;
background: #fafafa; background: #fafafa;
border-bottom: 1px solid rgba(0, 0, 0, 0.04); border-bottom: 1px solid rgb(0 0 0 / 4%);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
@@ -208,7 +208,7 @@ $accent: #0ea5e9;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px 16px; padding: 8px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.03); border-bottom: 1px solid rgb(0 0 0 / 3%);
&:nth-last-child(-n+2) { &:nth-last-child(-n+2) {
border-bottom: none; border-bottom: none;
@@ -239,7 +239,7 @@ $accent: #0ea5e9;
text-decoration: none; text-decoration: none;
padding: 7px 16px; padding: 7px 16px;
transition: background 0.1s; transition: background 0.1s;
border-bottom: 1px solid rgba(0, 0, 0, 0.03); border-bottom: 1px solid rgb(0 0 0 / 3%);
&:hover { &:hover {
background: #f0f9ff; background: #f0f9ff;
@@ -256,7 +256,7 @@ $accent: #0ea5e9;
:host-context(.dark-theme) { :host-context(.dark-theme) {
.qa-card, .info-card { .qa-card, .info-card {
background: #1e1e2e; background: #1e1e2e;
border-color: rgba(255, 255, 255, 0.06); border-color: rgb(255 255 255 / 6%);
} }
.qa-title { color: #e0e0e0; } .qa-title { color: #e0e0e0; }
@@ -267,7 +267,7 @@ $accent: #0ea5e9;
.quick-links a { .quick-links a {
color: #7dd3fc; color: #7dd3fc;
&:hover { background: rgba(255, 255, 255, 0.04); } &:hover { background: rgb(255 255 255 / 4%); }
} }
.search-input { .search-input {
@@ -276,13 +276,13 @@ $accent: #0ea5e9;
} }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 1024px) { @media (width <= 1024px) {
.quick-actions { .quick-actions {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.welcome-banner { .welcome-banner {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -302,7 +302,7 @@ $accent: #0ea5e9;
} }
} }
@media (max-width: 480px) { @media (width <= 480px) {
.quick-actions { .quick-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -0,0 +1,34 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { KycManagementComponent } from './kyc-management.component';
const routes: Routes = [
Route.withShell([
{
path: 'kyc-management',
data: { title: 'KYC Management', breadcrumb: 'KYC Management' },
children: [
{
path: '',
component: KycManagementComponent
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class KycManagementRoutingModule {}
@@ -0,0 +1,769 @@
<!--
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/.
-->
<div class="km-root">
<!-- Inner tabs -->
<div class="km-inner-nav">
<button class="km-itab" [class.active]="activeSection === 'alerts'" (click)="showSection('alerts')">
<fa-icon icon="bell"></fa-icon>
Alert Dashboard
<span class="km-cnt km-cnt--red">81</span>
</button>
<button class="km-itab" [class.active]="activeSection === 'notif'" (click)="showSection('notif')">
<fa-icon icon="paper-plane"></fa-icon>
Notification Scheduler
<span class="km-cnt km-cnt--amb">3</span>
</button>
<button class="km-itab" [class.active]="activeSection === 'audit'" (click)="showSection('audit')">
<fa-icon icon="clipboard-list"></fa-icon>
Audit Inventory
<span class="km-cnt">12</span>
</button>
<button class="km-itab" [class.active]="activeSection === 'status'" (click)="showSection('status')">
<fa-icon icon="lock"></fa-icon>
Account Status
<span class="km-cnt km-cnt--red">34</span>
</button>
</div>
<div class="km-main">
<!-- ALERT DASHBOARD -->
@if (activeSection === 'alerts') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">KYC Alert Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting alert report...')">
<fa-icon icon="download"></fa-icon> Export
</button>
<button class="km-btn km-btn-t" (click)="toast('Running scheduler...')">
<fa-icon icon="play"></fa-icon> Run Scheduler
</button>
</div>
</div>
<div class="km-stat-row">
<div class="km-stat" (click)="toast('Filtering: Expiring in 30 days')">
<div class="km-stat-n" style="color: var(--km-amber)">47</div>
<div class="km-stat-l">Expiring in 30 days</div>
<div class="km-stat-t" style="color: var(--km-amber)">▲ 12 this month</div>
</div>
<div class="km-stat" (click)="toast('Filtering: Debit frozen')">
<div class="km-stat-n" style="color: var(--km-orange)">23</div>
<div class="km-stat-l">Debit Frozen</div>
<div class="km-stat-t" style="color: var(--km-orange)">Action required</div>
</div>
<div class="km-stat" (click)="toast('Filtering: Inoperative')">
<div class="km-stat-n" style="color: var(--km-red)">8</div>
<div class="km-stat-l">Inoperative</div>
<div class="km-stat-t" style="color: var(--km-red)">Escalating</div>
</div>
<div class="km-stat" (click)="toast('Filtering: Dormant')">
<div class="km-stat-n" style="color: var(--km-g600)">3</div>
<div class="km-stat-l">Dormant</div>
<div class="km-stat-t" style="color: var(--km-g500)">RBI reportable</div>
</div>
<div class="km-stat" (click)="toast('Filtering: Notices sent today')">
<div class="km-stat-n" style="color: var(--km-blue)">18</div>
<div class="km-stat-l">Notices Today</div>
<div class="km-stat-t" style="color: var(--km-blue)">SMS + Email</div>
</div>
</div>
<div class="km-filters">
<select class="km-fsel">
<option>All Risk</option><option>Low</option><option>Mid</option><option>High</option>
</select>
<select class="km-fsel">
<option>All States</option><option>Alert Window</option><option>Debit Freeze</option>
<option>Inoperative</option><option>Dormant</option>
</select>
<select class="km-fsel">
<option>All Notices</option><option>Notice 1 Pending</option><option>Notice 2 Pending</option>
<option>Notice 3 Pending</option><option>All 3 Sent</option>
</select>
<input class="km-finp" type="text" placeholder="Search name or account..." />
<button class="km-btn km-btn-t" (click)="toast('Filters applied')">Apply</button>
</div>
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-a"><fa-icon icon="bell"></fa-icon></div>
<div class="km-ct">Accounts Requiring Action</div>
<span class="km-ch-hint">Showing {{ alertRows.length }} of 81 · Click row for client details</span>
</div>
<table class="km-tbl">
<thead>
<tr>
<th>Customer</th><th>Risk</th><th>Expiry Date</th><th>Days Left</th>
<th>Notices</th><th>State</th><th>Pending Action</th><th>Next Stage</th><th></th>
</tr>
</thead>
<tbody>
@for (row of alertRows; track row.id) {
<tr (click)="openModal(row.id)">
<td>
<div class="km-an">{{ row.name }}</div>
<div class="km-ai">{{ row.account }}</div>
</td>
<td><span class="km-b" [ngClass]="riskClass(row.risk)">{{ row.riskLabel.split(' ')[0] }}</span></td>
<td>{{ row.expiry }}</td>
<td>
<div class="km-pbar">
<span class="km-pbar-n">{{ row.daysLeft > 0 ? row.daysLeft : '—' }}</span>
<div class="km-pbar-bg">
<div
class="km-pbar-fill"
[style.width.%]="barWidth(row)"
[style.background]="barColor(row)"
></div>
</div>
</div>
</td>
<td><span class="km-b km-b-bl">{{ row.noticesSent }}/3</span></td>
<td><span class="km-sp" [ngClass]="stateClass(row.state)">{{ stateLabel(row.state) }}</span></td>
<td><span class="km-b km-b-am">{{ row.noticePending }}</span></td>
<td><span class="km-b km-b-or">{{ row.nextStage }}</span></td>
<td>
<button class="km-btn km-btn-ot" (click)="$event.stopPropagation(); openModal(row.id)">View</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- NOTIFICATION SCHEDULER -->
@if (activeSection === 'notif') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">Notification Scheduler</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Viewing scheduler history...')">
<fa-icon icon="history"></fa-icon> History
</button>
<button class="km-btn km-btn-t" (click)="toast('Dispatching selected notices...')">
<fa-icon icon="paper-plane"></fa-icon> Dispatch Now
</button>
</div>
</div>
<div class="km-notif-grid">
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-a"><fa-icon icon="calendar-alt"></fa-icon></div>
<div class="km-ct">3-Notice Dispatch Timeline</div>
<span class="km-ch-hint">Expiry: 30 Apr 2027 · Window: 31 Mar – 30 Apr</span>
</div>
<div class="km-cb">
<div class="km-ntl">
<div class="km-nt">
<div class="km-nd-col"><div class="km-nd km-nd1">1</div><div class="km-nl"></div></div>
<div>
<div class="km-nh">Notice 1 — Alert window opens</div>
<div class="km-ns">31 March 2027 · Auto-scheduled · <span class="km-ok">✓ Delivered</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col"><div class="km-nd km-nd2">2</div><div class="km-nl"></div></div>
<div>
<div class="km-nh">Notice 2 — Follow-up</div>
<div class="km-ns">10 April 2027 · +10 days · <span class="km-pending">⏳ Pending</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR Call</span>
<span class="km-cp km-cp-post">Reg. Post</span>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col"><div class="km-nd km-nd3">3</div><div class="km-nl"></div></div>
<div>
<div class="km-nh">Notice 3 — Final notice</div>
<div class="km-ns">20 April 2027 · +20 days · <span class="km-fail">✗ Not dispatched</span></div>
<div class="km-ntags">
<span class="km-cp km-cp-sms">SMS</span>
<span class="km-cp km-cp-mail">Email</span>
<span class="km-cp km-cp-ivr">IVR</span>
<span class="km-cp km-cp-post">Reg. Post</span>
<span class="km-cp km-cp-vis">Branch Visit</span>
</div>
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="toast('Notice 3 scheduled for 20 Apr 2027')">Schedule Notice 3</button>
<button class="km-btn km-btn-ot" (click)="toast('Video KYC link added to Notice 3')">+ Video KYC Link</button>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col"><div class="km-nd km-ndf">!</div><div class="km-nl"></div></div>
<div>
<div class="km-nh km-warn">Debit Freeze — if no action</div>
<div class="km-ns">30 April 2027 · Auto-triggered on expiry</div>
<div class="km-ntags">
<span class="km-b km-b-am">Auto-triggered</span>
<span class="km-b km-b-gy">Credits allowed</span>
<span class="km-b km-b-gy">Debits blocked</span>
</div>
</div>
</div>
<div class="km-nt">
<div class="km-nd-col"><div class="km-nd km-ndi">!</div><div class="km-nl"></div></div>
<div>
<div class="km-nh km-warn-o">Inoperative — +60 days</div>
<div class="km-ns">30 June 2027 · No compliance</div>
<div class="km-ntags"><span class="km-b km-b-or">All txns blocked</span></div>
</div>
</div>
<div class="km-nt km-nt-last">
<div class="km-nd-col"><div class="km-nd km-ndd">!</div></div>
<div>
<div class="km-nh km-warn-d">Dormant — +150 days</div>
<div class="km-ns">30 September 2027</div>
<div class="km-ntags">
<span class="km-b km-b-gy">Dormant</span>
<span class="km-b km-b-re">RBI reporting</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="km-side">
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-b"><fa-icon icon="broadcast-tower"></fa-icon></div>
<div class="km-ct">Channels — Notice 3</div>
</div>
<div class="km-cb km-channels">
<div class="km-channel km-channel-on" (click)="toast('SMS toggled')">
<div class="km-channel-i">📱</div>
<div class="km-channel-n">SMS</div>
<div class="km-channel-d">Registered mobile</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Email toggled')">
<div class="km-channel-i">📧</div>
<div class="km-channel-n">Email</div>
<div class="km-channel-d">Registered email</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('IVR toggled')">
<div class="km-channel-i">📞</div>
<div class="km-channel-n">IVR Call</div>
<div class="km-channel-d">Outbound IVR</div>
</div>
<div class="km-channel km-channel-on" (click)="toast('Reg Post toggled')">
<div class="km-channel-i">📮</div>
<div class="km-channel-n">Reg. Post</div>
<div class="km-channel-d">India Post</div>
</div>
<div class="km-channel" (click)="toast('Branch visit toggled')">
<div class="km-channel-i">🏦</div>
<div class="km-channel-n">Branch Visit</div>
<div class="km-channel-d">Manual</div>
</div>
<div class="km-channel" (click)="toast('Video KYC toggled')">
<div class="km-channel-i">📹</div>
<div class="km-channel-n">Video KYC</div>
<div class="km-channel-d">V-CIP app</div>
</div>
</div>
</div>
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-b"><fa-icon icon="envelope"></fa-icon></div>
<div class="km-ct">India Post Notice</div>
</div>
<div class="km-cb">
<div class="km-mi"><div class="km-ml">Dispatch Date</div><div class="km-mv">10 April 2027</div></div>
<div class="km-mi"><div class="km-ml">Reg. Post Docket No.</div><div class="km-mv km-mono">RP-BPL-20270410-443</div></div>
<div class="km-mi"><div class="km-ml">Status</div><div class="km-mv"><span class="km-b km-b-bl">In Transit</span></div></div>
<button class="km-btn km-btn-g km-w" (click)="toast('Tracking RP-BPL-20270410-443 on India Post...')">Track Delivery</button>
</div>
</div>
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-t"><fa-icon icon="building"></fa-icon></div>
<div class="km-ct">Branch Visit</div>
</div>
<div class="km-cb">
<div class="km-ml">Scheduled Date</div>
<input type="date" class="km-finp km-w" value="2027-04-20" />
<div class="km-ml" style="margin-top: 8px">Officer</div>
<select class="km-fsel km-w">
<option>Anupam Mishra (BPL Main)</option>
<option>Rekha Sharma (BPL South)</option>
</select>
<button class="km-btn km-btn-t km-w" (click)="toast('Branch visit scheduled — Anupam Mishra')">
Schedule Visit
</button>
</div>
</div>
</div>
</div>
</div>
}
<!-- AUDIT INVENTORY -->
@if (activeSection === 'audit') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">Audit Inventory</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting CSV...')">
<fa-icon icon="download"></fa-icon> Export CSV
</button>
<button class="km-btn km-btn-n" (click)="toast('Generating RBI audit report...')">
<fa-icon icon="file-alt"></fa-icon> RBI Report
</button>
</div>
</div>
<div class="km-filters">
<select class="km-fsel">
<option>All Channels</option><option>SMS</option><option>Email</option><option>IVR</option>
<option>Reg. Post</option><option>Branch Visit</option><option>Video KYC</option>
</select>
<select class="km-fsel">
<option>All Statuses</option><option>Delivered</option><option>In Transit</option>
<option>Pending</option><option>Failed</option>
</select>
<select class="km-fsel">
<option>All Notice Nos.</option><option>Notice 1</option><option>Notice 2</option><option>Notice 3</option>
</select>
<input class="km-finp" type="text" placeholder="Search account, name or reference..." />
<button class="km-btn km-btn-t" (click)="toast('Filters applied')">Apply</button>
</div>
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-t"><fa-icon icon="clipboard-list"></fa-icon></div>
<div class="km-ct">KYC Notice Log</div>
<span class="km-ch-hint">Click row for client details</span>
</div>
<table class="km-tbl">
<thead>
<tr>
<th>#</th><th>Account / Customer</th><th>Notice</th><th>Channel</th>
<th>Dispatched</th><th>Reference / Docket</th><th>Delivery</th>
<th>Client Action</th><th>Officer</th>
</tr>
</thead>
<tbody>
<tr (click)="openModal('rajesh')">
<td class="km-num">001</td>
<td><div class="km-an">Rajesh K. Sharma</div><div class="km-ai">ACC-BPL-2024-00847</div></td>
<td><span class="km-b km-b-bl">Notice 1</span></td>
<td>SMS + Email</td>
<td>31 Mar 2027</td>
<td><span class="km-ref">MSG-2027-0331-001</span></td>
<td><span class="km-del km-d-ok">✓ Delivered</span></td>
<td><span class="km-del km-d-pe">Awaited</span></td>
<td class="km-small">Scheduler</td>
</tr>
<tr (click)="openModal('rajesh')">
<td class="km-num">002</td>
<td><div class="km-an">Rajesh K. Sharma</div><div class="km-ai">ACC-BPL-2024-00847</div></td>
<td><span class="km-b km-b-am">Notice 2</span></td>
<td>Reg. Post + IVR</td>
<td>10 Apr 2027</td>
<td><span class="km-ref">RP-BPL-20270410-443</span></td>
<td><span class="km-del km-d-tr">In Transit</span></td>
<td><span class="km-del km-d-pe">Awaited</span></td>
<td class="km-small">Scheduler</td>
</tr>
<tr (click)="openModal('priya')">
<td class="km-num">003</td>
<td><div class="km-an">Priya Malhotra</div><div class="km-ai">ACC-BPL-2021-00231</div></td>
<td><span class="km-b km-b-bl">Notice 1</span></td>
<td>SMS + Email</td>
<td>10 Mar 2027</td>
<td><span class="km-ref">MSG-2027-0310-003</span></td>
<td><span class="km-del km-d-ok">✓ Delivered</span></td>
<td><span class="km-del km-d-fa">No Response</span></td>
<td class="km-small">Scheduler</td>
</tr>
<tr (click)="openModal('priya')">
<td class="km-num">004</td>
<td><div class="km-an">Priya Malhotra</div><div class="km-ai">ACC-BPL-2021-00231</div></td>
<td><span class="km-b km-b-am">Notice 2</span></td>
<td>Reg. Post</td>
<td>20 Mar 2027</td>
<td><span class="km-ref">RP-BPL-20270320-091</span></td>
<td><span class="km-del km-d-ok">✓ Delivered</span></td>
<td><span class="km-del km-d-fa">No Response</span></td>
<td class="km-small">Scheduler</td>
</tr>
<tr (click)="openModal('priya')">
<td class="km-num">005</td>
<td><div class="km-an">Priya Malhotra</div><div class="km-ai">ACC-BPL-2021-00231</div></td>
<td><span class="km-b km-b-or">Notice 3</span></td>
<td>Branch Visit</td>
<td>30 Mar 2027</td>
<td><span class="km-ref">VIS-BPL-20270330-012</span></td>
<td><span class="km-del km-d-ok">✓ Done</span></td>
<td><span class="km-del km-d-fa">Refused</span></td>
<td class="km-small">A. Mishra</td>
</tr>
<tr (click)="openModal('suresh')">
<td class="km-num">006</td>
<td><div class="km-an">Suresh Patel</div><div class="km-ai">ACC-BPL-2019-00112</div></td>
<td><span class="km-b km-b-or">Notice 3</span></td>
<td>Video KYC</td>
<td>5 Feb 2027</td>
<td><span class="km-ref">VKYC-2027-0205-007</span></td>
<td><span class="km-del km-d-fa">No Response</span></td>
<td><span class="km-del km-d-fa">No Action</span></td>
<td class="km-small">System</td>
</tr>
</tbody>
</table>
<div class="km-pagination">
<span class="km-ch-hint">Showing 6 of 12</span>
<div class="km-actions">
<button class="km-btn km-btn-g">← Prev</button>
<button class="km-btn km-btn-t" (click)="toast('Loading next page...')">Next →</button>
</div>
</div>
</div>
</div>
}
<!-- ACCOUNT STATUS -->
@if (activeSection === 'status') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">Account Status Management</span>
<button class="km-btn km-btn-n" (click)="toast('Running state transitions...')">
<fa-icon icon="play"></fa-icon> Run Transitions
</button>
</div>
<div class="km-sc-row">
<div class="km-sc km-sc-ac" (click)="toast('Filtering: Active')">
<div class="km-sc-l">Active</div><div class="km-sc-n">1,847</div><div class="km-sc-d">KYC valid</div>
</div>
<div class="km-sc km-sc-al" (click)="toast('Filtering: Alert window')">
<div class="km-sc-l">Alert Window</div><div class="km-sc-n">47</div><div class="km-sc-d">Expiring &lt; 30d</div>
</div>
<div class="km-sc km-sc-fr" (click)="toast('Filtering: Debit Freeze')">
<div class="km-sc-l">Debit Freeze</div><div class="km-sc-n">23</div><div class="km-sc-d">Debits blocked</div>
</div>
<div class="km-sc km-sc-in" (click)="toast('Filtering: Inoperative')">
<div class="km-sc-l">Inoperative</div><div class="km-sc-n">8</div><div class="km-sc-d">All txns blocked</div>
</div>
<div class="km-sc km-sc-do" (click)="toast('Filtering: Dormant')">
<div class="km-sc-l">Dormant</div><div class="km-sc-n">3</div><div class="km-sc-d">RBI reportable</div>
</div>
</div>
<div class="km-r2">
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-a"><fa-icon icon="cog"></fa-icon></div>
<div class="km-ct">Auto-Transition Rules</div>
</div>
<div class="km-cb-flush">
<div class="km-rule">
<div class="km-rule-h"><span class="km-rule-t">Active → Debit Freeze</span><span class="km-b km-b-gr">Enabled</span></div>
<div class="km-rule-d">KYC expires + 3 notices sent + no response → debits blocked, credits allowed</div>
<div class="km-rule-tags">
<span class="km-b km-b-gy">Window: 31 Mar – 30 Apr</span>
<span class="km-b km-b-gy">3/3 notices</span>
</div>
</div>
<div class="km-rule">
<div class="km-rule-h"><span class="km-rule-t">Debit Freeze → Inoperative</span><span class="km-b km-b-gr">Enabled</span></div>
<div class="km-rule-d">+60 days from expiry, still unresolved → all transactions blocked</div>
<div class="km-rule-tags"><span class="km-b km-b-gy">Apr 30 – Jun 30</span></div>
</div>
<div class="km-rule km-rule-last">
<div class="km-rule-h"><span class="km-rule-t">Inoperative → Dormant</span><span class="km-b km-b-gr">Enabled</span></div>
<div class="km-rule-d">+150 days from expiry → dormant, RBI Dormant Account reporting triggered</div>
<div class="km-rule-tags">
<span class="km-b km-b-gy">Jun 30 – Sep 30</span>
<span class="km-b km-b-re">RBI notification</span>
</div>
</div>
</div>
</div>
<div class="km-card">
<div class="km-ch">
<div class="km-ci km-ci-r"><fa-icon icon="user-shield"></fa-icon></div>
<div class="km-ct">Manual Override</div>
<span class="km-b km-b-am km-ml-auto">Maker-Checker</span>
</div>
<div class="km-cb">
<div class="km-ml">Account</div>
<select class="km-fsel km-w">
<option>ACC-BPL-2021-00231 — Priya Malhotra</option>
<option>ACC-BPL-2019-00112 — Suresh Patel</option>
</select>
<div class="km-ml" style="margin-top: 10px">Current State</div>
<div><span class="km-sp km-sp-fr">Debit Freeze</span></div>
<div class="km-ml" style="margin-top: 10px">Override Action</div>
<select class="km-fsel km-w">
<option>Select action...</option>
<option>Unfreeze — KYC Renewed</option>
<option>Unfreeze — Manager Override</option>
<option>Mark Inoperative</option>
<option>Mark Dormant</option>
</select>
<div class="km-ml" style="margin-top: 10px">Reason</div>
<textarea class="km-finp km-w km-ta" rows="2" placeholder="Reason for override..."></textarea>
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="toast('Override submitted for checker approval')">
Submit for Approval
</button>
<button class="km-btn km-btn-g" (click)="toast('Cleared')">Clear</button>
</div>
<div class="km-warn-box">
<div class="km-warn-t">⚠ Maker-Checker Required</div>
<div class="km-warn-d">All manual changes require second officer approval and are logged in audit inventory.</div>
</div>
</div>
</div>
</div>
<div class="km-card km-mt">
<div class="km-ch">
<div class="km-ci km-ci-t"><fa-icon icon="exchange-alt"></fa-icon></div>
<div class="km-ct">Recent State Transitions</div>
<span class="km-ch-hint">Click row for client details</span>
</div>
<table class="km-tbl">
<thead>
<tr><th>Account</th><th>From</th><th>To</th><th>Trigger</th><th>Date</th><th>By</th><th>Action</th></tr>
</thead>
<tbody>
<tr (click)="openModal('priya')">
<td><div class="km-an">Priya Malhotra</div><div class="km-ai">ACC-BPL-2021-00231</div></td>
<td><span class="km-sp km-sp-ac">Active</span></td>
<td><span class="km-sp km-sp-fr">Debit Freeze</span></td>
<td><span class="km-b km-b-am">Auto — KYC Expired</span></td>
<td>10 Apr 2027</td>
<td class="km-small">Scheduler</td>
<td><button class="km-btn km-btn-ot" (click)="$event.stopPropagation(); toast('Unfreeze initiated')">Unfreeze</button></td>
</tr>
<tr (click)="openModal('suresh')">
<td><div class="km-an">Suresh Patel</div><div class="km-ai">ACC-BPL-2019-00112</div></td>
<td><span class="km-sp km-sp-fr">Debit Freeze</span></td>
<td><span class="km-sp km-sp-in">Inoperative</span></td>
<td><span class="km-b km-b-or">Auto — 60d elapsed</span></td>
<td>15 May 2027</td>
<td class="km-small">Scheduler</td>
<td><button class="km-btn km-btn-g" (click)="$event.stopPropagation(); toast('Override needs approval')">Override</button></td>
</tr>
<tr (click)="openModal('kavitha')">
<td><div class="km-an">Kavitha Nair</div><div class="km-ai">ACC-BPL-2018-00088</div></td>
<td><span class="km-sp km-sp-in">Inoperative</span></td>
<td><span class="km-sp km-sp-do">Dormant</span></td>
<td><span class="km-b km-b-re">Auto — 150d elapsed</span></td>
<td>1 Jun 2027</td>
<td class="km-small">Scheduler</td>
<td><button class="km-btn km-btn-g" (click)="$event.stopPropagation(); toast('Reversal needs RM + RBI')">Reverse</button></td>
</tr>
</tbody>
</table>
</div>
</div>
}
</div>
<!-- CLIENT DETAIL MODAL -->
@if (modalOpen && current) {
<div class="km-overlay" (click)="closeModal()">
<div class="km-modal" (click)="$event.stopPropagation()">
<div class="km-mh">
<div class="km-mh-av">{{ current.av }}</div>
<div>
<div class="km-mh-name">{{ current.name }}</div>
<div class="km-mh-meta">{{ current.meta }}</div>
</div>
<span class="km-sp km-ml-12" [ngClass]="stateClass(current.state)">{{ stateLabel(current.state) }}</span>
<span class="km-b" [ngClass]="riskClass(current.risk)">{{ current.riskLabel }}</span>
<div class="km-mclose" (click)="closeModal()">✕</div>
</div>
<div class="km-mbody">
<div class="km-mtab-bar">
<button class="km-mtab" [class.active]="modalTab === 'overview'" (click)="showMTab('overview')">Overview</button>
<button class="km-mtab" [class.active]="modalTab === 'timeline'" (click)="showMTab('timeline')">Alert Timeline</button>
<button class="km-mtab" [class.active]="modalTab === 'notices'" (click)="showMTab('notices')">Notices Sent</button>
<button class="km-mtab" [class.active]="modalTab === 'evidence'" (click)="showMTab('evidence')">Evidence</button>
<button class="km-mtab" [class.active]="modalTab === 'action'" (click)="showMTab('action')">Take Action</button>
</div>
<!-- OVERVIEW -->
@if (modalTab === 'overview') {
<div class="km-slbl">Compliance Journey</div>
<div class="km-prog-steps">
@for (step of getStepperState(current); track step.label) {
<div class="km-ps" [ngClass]="'km-ps-' + step.state">
<div class="km-ps-dot">{{ stepIcon(step.state) }}</div>
<div class="km-ps-label">{{ step.label }}</div>
</div>
}
</div>
<div class="km-next-banner" [ngClass]="'km-nb-' + current.state">
<div class="km-nb-icon">⚡</div>
<div>
<div class="km-nb-title">{{ stateLabel(current.state) }} — {{ current.daysLeft }} days to expiry</div>
<div class="km-nb-sub">Next pending: <strong>{{ current.noticePending }}</strong>. Next stage: <strong>{{ current.nextStage }}</strong>.</div>
</div>
</div>
<div class="km-mstat-row">
<div class="km-mstat"><div class="km-mstat-n">{{ current.noticesSent }}</div><div class="km-mstat-l">Notices Sent</div></div>
<div class="km-mstat"><div class="km-mstat-n">{{ noticesPending(current) }}</div><div class="km-mstat-l">Notices Pending</div></div>
<div class="km-mstat"><div class="km-mstat-n">{{ current.actionTaken.length }}</div><div class="km-mstat-l">Actions Taken</div></div>
<div class="km-mstat"><div class="km-mstat-n">{{ current.pendingActions.length }}</div><div class="km-mstat-l">Pending Actions</div></div>
</div>
<div class="km-r2">
<div>
<div class="km-slbl">KYC Information</div>
<div class="km-mg">
<div class="km-mi"><div class="km-ml">PAN</div><div class="km-mv km-mono">{{ current.kyc.pan }}</div></div>
<div class="km-mi"><div class="km-ml">PAN Status</div><div class="km-mv"><span class="km-b km-b-gr">{{ current.kyc.panStatus }}</span></div></div>
<div class="km-mi"><div class="km-ml">Aadhaar</div><div class="km-mv km-mono">{{ current.kyc.aadhaar }}</div></div>
<div class="km-mi"><div class="km-ml">Aadhaar Status</div><div class="km-mv"><span class="km-b km-b-gr">{{ current.kyc.aadhaarStatus }}</span></div></div>
<div class="km-mi"><div class="km-ml">KYC Validity</div><div class="km-mv">{{ current.kyc.validity }}</div></div>
<div class="km-mi"><div class="km-ml">Expiry Date</div><div class="km-mv km-red">{{ current.expiry }}</div></div>
</div>
</div>
<div>
<div class="km-slbl">Contact Details</div>
<div class="km-mg">
<div class="km-mi"><div class="km-ml">Mobile</div><div class="km-mv">{{ current.contact.mobile }}</div></div>
<div class="km-mi"><div class="km-ml">Email</div><div class="km-mv km-small">{{ current.contact.email }}</div></div>
<div class="km-mi km-mi-span"><div class="km-ml">Address</div><div class="km-mv km-small">{{ current.contact.address }}</div></div>
<div class="km-mi"><div class="km-ml">Branch</div><div class="km-mv">{{ current.contact.branch }}</div></div>
</div>
</div>
</div>
}
<!-- TIMELINE -->
@if (modalTab === 'timeline') {
<div class="km-slbl">Notice & Escalation Timeline</div>
<div class="km-mtl">
@for (item of current.actionTaken; track $index) {
<div class="km-mti">
<div class="km-mti-dot km-mti-done">✓</div>
<div>
<div class="km-mti-text km-done">{{ item }}</div>
<div class="km-mti-sub">Completed</div>
</div>
</div>
}
@for (item of current.pendingActions; track $index) {
<div class="km-mti">
<div class="km-mti-dot km-mti-pend">!</div>
<div>
<div class="km-mti-text km-pend">{{ item }}</div>
<div class="km-mti-sub">Pending action</div>
</div>
</div>
}
</div>
}
<!-- NOTICES -->
@if (modalTab === 'notices') {
<div class="km-slbl">All Notices Dispatched</div>
<table class="km-tbl">
<thead>
<tr><th>#</th><th>Notice</th><th>Channel</th><th>Date</th><th>Reference</th><th>Delivery</th><th>Response</th></tr>
</thead>
<tbody>
@for (n of current.notices; track n.ref; let i = $index) {
<tr>
<td class="km-num">{{ i + 1 }}</td>
<td><span class="km-b" [ngClass]="i === 0 ? 'km-b-bl' : i === 1 ? 'km-b-am' : 'km-b-or'">{{ n.no }}</span></td>
<td class="km-small">{{ n.channel }}</td>
<td>{{ n.date }}</td>
<td><span class="km-ref">{{ n.ref }}</span></td>
<td><span class="km-del" [ngClass]="deliveryClass(n.delivery)">{{ n.delivery }}</span></td>
<td><span class="km-del" [ngClass]="responseClass(n.response)">{{ n.response }}</span></td>
</tr>
}
@if (noticesPending(current) > 0) {
<tr><td colspan="7" class="km-pending-row">⏳ {{ noticesPending(current) }} notice(s) still pending dispatch</td></tr>
}
</tbody>
</table>
}
<!-- EVIDENCE -->
@if (modalTab === 'evidence') {
@if (!current.evidence) {
<div class="km-empty">
<div class="km-empty-i">📄</div>
<div>No evidence collected yet for this client.</div>
<div class="km-empty-s">Evidence is captured during branch visits or video KYC sessions.</div>
</div>
} @else {
<div class="km-slbl">{{ current.evidence.type === 'visit' ? 'Branch Visit Evidence' : 'Video KYC Session Log' }}</div>
<div class="km-r2 km-mb">
<div class="km-mi"><div class="km-ml">Date</div><div class="km-mv">{{ current.evidence.date }}</div></div>
<div class="km-mi"><div class="km-ml">Officer / Agent</div><div class="km-mv">{{ current.evidence.officer }}</div></div>
@if (current.evidence.type === 'visit') {
<div class="km-mi"><div class="km-ml">GPS Coordinates</div><div class="km-mv km-mono">{{ current.evidence.gps }}</div></div>
<div class="km-mi"><div class="km-ml">Location</div><div class="km-mv km-small">{{ current.evidence.location }}</div></div>
}
<div class="km-mi"><div class="km-ml">Reference / Docket</div><div class="km-mv km-mono">{{ current.evidence.docket || current.evidence.ref }}</div></div>
<div class="km-mi"><div class="km-ml">Outcome</div><div class="km-mv"><span class="km-del km-d-fa">No Compliance</span></div></div>
</div>
@if (current.evidence.type === 'visit') {
<div class="km-slbl">Map Placeholder</div>
<div class="km-map">{{ current.evidence.gps }}<div class="km-small">{{ current.evidence.location }}</div></div>
<div class="km-slbl">Photo Evidence</div>
<div class="km-photo">Visit photo uploaded · Tap to view</div>
}
<div class="km-slbl">Officer Notes</div>
<div class="km-notes">{{ current.evidence.outcome }}</div>
<div class="km-row-actions">
<button class="km-btn km-btn-t" (click)="toast('Downloading evidence package...')">Download Package</button>
<button class="km-btn km-btn-g" (click)="toast('Sharing with RBI audit team...')">Share for Audit</button>
</div>
}
}
<!-- ACTION -->
@if (modalTab === 'action') {
<div class="km-slbl">Available Actions</div>
<div class="km-action-list">
@for (action of current.pendingActions; track action) {
<div class="km-action-row">
<div>
<div class="km-action-t">{{ action }}</div>
<div class="km-action-d">Initiate via available channels</div>
</div>
<button class="km-btn km-btn-t" (click)="toast(action + ' initiated for ' + current.name)">Take Action</button>
</div>
}
</div>
}
</div>
</div>
</div>
}
<!-- TOAST -->
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,485 @@
/**
* 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 } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
interface KmNotice {
no: string;
channel: string;
date: string;
ref: string;
delivery: string;
response: string;
}
interface KmEvidence {
type: 'visit' | 'vkyc';
date: string;
officer: string;
gps?: string;
location?: string;
ref?: string;
outcome: string;
docket?: string;
}
interface KmClient {
id: string;
av: string;
name: string;
meta: string;
state: 'alert' | 'freeze' | 'inop' | 'dormant' | 'urgent';
risk: 'low' | 'mid' | 'high';
riskLabel: string;
expiry: string;
daysLeft: number;
account: string;
kyc: {
type: string;
initiated: string;
lastVerified: string;
validity: string;
pan: string;
aadhaar: string;
panStatus: string;
aadhaarStatus: string;
panLink: string;
};
contact: {
mobile: string;
email: string;
address: string;
branch: string;
};
notices: KmNotice[];
noticesSent: number;
noticePending: string;
nextStage: string;
pendingActions: string[];
actionTaken: string[];
evidence: KmEvidence | null;
}
@Component({
selector: 'mifosx-kyc-management',
templateUrl: './kyc-management.component.html',
styleUrls: ['./kyc-management.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class KycManagementComponent {
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
modalOpen = false;
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
current: KmClient | null = null;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
clients: Record<string, KmClient> = {
rajesh: {
id: 'rajesh',
av: 'RS',
name: 'Rajesh Kumar Sharma',
meta: 'ACC-BPL-2024-00847 · Savings A/c · Bhopal Main',
account: 'ACC-BPL-2024-00847',
state: 'alert',
risk: 'low',
riskLabel: 'Low Risk',
expiry: '30 Apr 2027',
daysLeft: 15,
kyc: {
type: 'Individual',
initiated: '4 Apr 2026',
lastVerified: '4 Apr 2026',
validity: '10 Years',
pan: 'AQRPV8345E',
aadhaar: 'XXXXXXXX6039',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-99041',
email: 'r****@gmail.com',
address: 'House No. 42, Sector 5, Bhopal MP 462001',
branch: 'Bhopal Main'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '31 Mar 2027', ref: 'MSG-2027-0331-001', delivery: 'Delivered', response: 'No Action' },
{ no: 'N2', channel: 'Reg. Post + IVR', date: '10 Apr 2027', ref: 'RP-BPL-20270410-443', delivery: 'In Transit', response: 'Pending' }
],
noticesSent: 2,
noticePending: 'Notice 3',
nextStage: 'Debit Freeze',
pendingActions: ['Send Notice 3 (due 20 Apr 2027)', 'Schedule Branch Visit', 'Send Video KYC link'],
actionTaken: ['Notice 1 delivered', 'Notice 2 dispatched via Reg. Post'],
evidence: null
},
priya: {
id: 'priya',
av: 'PM',
name: 'Priya Malhotra',
meta: 'ACC-BPL-2021-00231 · Savings A/c · Bhopal Main',
account: 'ACC-BPL-2021-00231',
state: 'freeze',
risk: 'mid',
riskLabel: 'Mid Risk',
expiry: '10 Apr 2027',
daysLeft: 0,
kyc: {
type: 'Individual',
initiated: '10 Apr 2019',
lastVerified: '10 Apr 2019',
validity: '8 Years',
pan: 'BKJPV1234A',
aadhaar: 'XXXXXXXX7721',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-55321',
email: 'p****@yahoo.com',
address: 'Flat 12B, Arera Colony, Bhopal MP 462016',
branch: 'Bhopal Main'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '10 Mar 2027', ref: 'MSG-2027-0310-003', delivery: 'Delivered', response: 'No Response' },
{ no: 'N2', channel: 'Reg. Post', date: '20 Mar 2027', ref: 'RP-BPL-20270320-091', delivery: 'Delivered', response: 'No Response' },
{ no: 'N3', channel: 'Branch Visit', date: '30 Mar 2027', ref: 'VIS-BPL-20270330-012', delivery: 'Visit Done', response: 'Refused' }
],
noticesSent: 3,
noticePending: 'None — all 3 sent',
nextStage: 'Inoperative by 30 Jun 2027',
pendingActions: ['Account in Debit Freeze', 'Inoperative transition due 30 Jun 2027', 'Maker-checker unfreeze pending'],
actionTaken: ['Notice 1 delivered', 'Notice 2 delivered via Reg. Post', 'Notice 3 — Branch visit done, client refused', 'Debit Freeze applied 10 Apr 2027'],
evidence: {
type: 'visit',
date: '30 Mar 2027',
officer: 'Anupam Mishra',
gps: '23.2599° N, 77.4126° E',
location: 'Arera Colony, Bhopal',
outcome: 'Client refused to sign self-declaration form. Informed about debit freeze implications.',
docket: 'VIS-BPL-20270330-012'
}
},
suresh: {
id: 'suresh',
av: 'SP',
name: 'Suresh Patel',
meta: 'ACC-BPL-2019-00112 · Savings A/c · Bhopal South',
account: 'ACC-BPL-2019-00112',
state: 'inop',
risk: 'high',
riskLabel: 'High Risk',
expiry: '15 Mar 2027',
daysLeft: 0,
kyc: {
type: 'Individual',
initiated: '15 Mar 2025',
lastVerified: '15 Mar 2025',
validity: '2 Years',
pan: 'CXRTS5678B',
aadhaar: 'XXXXXXXX2211',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-88765',
email: 's****@gmail.com',
address: 'Village Ratibad, Bhopal MP 462044',
branch: 'Bhopal South'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '13 Feb 2027', ref: 'MSG-2027-0213-006', delivery: 'Delivered', response: 'No Response' },
{ no: 'N2', channel: 'Reg. Post + IVR', date: '23 Feb 2027', ref: 'RP-BPL-20270223-044', delivery: 'Delivered', response: 'No Response' },
{ no: 'N3', channel: 'Video KYC', date: '5 Mar 2027', ref: 'VKYC-2027-0305-007', delivery: 'No Response', response: 'No Action' }
],
noticesSent: 3,
noticePending: 'None',
nextStage: 'Dormant by 30 Sep 2027',
pendingActions: ['Account Inoperative — all txns blocked', 'Dormant transition due 30 Sep 2027', 'RBI Dormant Account reporting'],
actionTaken: ['Notice 1 delivered', 'Notice 2 delivered via Reg. Post', 'Notice 3 — Video KYC invite sent (no response)', 'Debit Freeze 15 Mar 2027', 'Inoperative 15 May 2027'],
evidence: {
type: 'vkyc',
date: '5 Mar 2027',
officer: 'System',
ref: 'VKYC-2027-0305-007',
outcome: 'Video KYC session link sent. Client did not join within 48 hours. Session auto-expired.'
}
},
kavitha: {
id: 'kavitha',
av: 'KN',
name: 'Kavitha Nair',
meta: 'ACC-BPL-2018-00088 · Savings A/c · Bhopal East',
account: 'ACC-BPL-2018-00088',
state: 'dormant',
risk: 'mid',
riskLabel: 'Mid Risk',
expiry: '1 Jan 2027',
daysLeft: 0,
kyc: {
type: 'Individual',
initiated: '1 Jan 2019',
lastVerified: '1 Jan 2019',
validity: '8 Years',
pan: 'DVJNK9012C',
aadhaar: 'XXXXXXXX4492',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-11092',
email: 'k****@rediff.com',
address: 'Plot 7, Awadhpuri, Bhopal MP 462010',
branch: 'Bhopal East'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '1 Dec 2026', ref: 'MSG-2026-1201-009', delivery: 'Delivered', response: 'No Response' },
{ no: 'N2', channel: 'Reg. Post', date: '11 Dec 2026', ref: 'RP-BPL-20261211-077', delivery: 'Delivered', response: 'No Response' },
{ no: 'N3', channel: 'Branch Visit', date: '22 Dec 2026', ref: 'VIS-BPL-20261222-019', delivery: 'Not Found', response: 'Not Found at Address' }
],
noticesSent: 3,
noticePending: 'None',
nextStage: 'Dormant (Final) — RBI Reporting',
pendingActions: ['RBI Dormant Account Report submission', 'Branch Manager approval for any reversal', 'Regional Manager sign-off required'],
actionTaken: ['Notice 1–3 sent, all no response', 'Debit Freeze 1 Jan 2027', 'Inoperative 2 Mar 2027', 'Dormant 1 Jun 2027', 'RBI report auto-generated'],
evidence: {
type: 'visit',
date: '22 Dec 2026',
officer: 'Rekha Sharma',
gps: '23.2491° N, 77.4384° E',
location: 'Awadhpuri, Bhopal',
outcome: 'Client not found at registered address. Neighbours confirmed relocation. Address verification required.',
docket: 'VIS-BPL-20261222-019'
}
},
amit: {
id: 'amit',
av: 'AS',
name: 'Amit Singh',
meta: 'ACC-BPL-2024-01103 · Savings A/c · Bhopal Main',
account: 'ACC-BPL-2024-01103',
state: 'alert',
risk: 'low',
riskLabel: 'Low Risk',
expiry: '5 May 2027',
daysLeft: 20,
kyc: {
type: 'Individual',
initiated: '5 May 2016',
lastVerified: '5 May 2016',
validity: '10 Years',
pan: 'EKJPS4321D',
aadhaar: 'XXXXXXXX8834',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-22334',
email: 'a****@gmail.com',
address: 'C-22, Shivaji Nagar, Bhopal MP 462016',
branch: 'Bhopal Main'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '5 Apr 2027', ref: 'MSG-2027-0405-011', delivery: 'Delivered', response: 'No Action' }
],
noticesSent: 1,
noticePending: 'Notice 2 & 3',
nextStage: 'Debit Freeze if no action',
pendingActions: ['Send Notice 2 (due 15 Apr 2027)', 'Send Notice 3 (due 25 Apr 2027)', 'Branch visit if needed'],
actionTaken: ['Notice 1 delivered 5 Apr 2027'],
evidence: null
},
meena: {
id: 'meena',
av: 'MJ',
name: 'Meena Joshi',
meta: 'ACC-BPL-2022-00567 · Savings A/c · Bhopal North',
account: 'ACC-BPL-2022-00567',
state: 'urgent',
risk: 'high',
riskLabel: 'High Risk',
expiry: '28 Apr 2027',
daysLeft: 3,
kyc: {
type: 'Individual',
initiated: '28 Apr 2025',
lastVerified: '28 Apr 2025',
validity: '2 Years',
pan: 'FJLMN6789E',
aadhaar: 'XXXXXXXX3309',
panStatus: 'Verified',
aadhaarStatus: 'Verified',
panLink: 'Linked'
},
contact: {
mobile: '+91-XXXXX-77654',
email: 'm****@gmail.com',
address: 'Lane 4, Shyamla Hills, Bhopal MP 462002',
branch: 'Bhopal North'
},
notices: [
{ no: 'N1', channel: 'SMS + Email', date: '29 Mar 2027', ref: 'MSG-2027-0329-014', delivery: 'Delivered', response: 'No Action' },
{ no: 'N2', channel: 'Reg. Post + IVR', date: '8 Apr 2027', ref: 'RP-BPL-20270408-088', delivery: 'Delivered', response: 'No Response' }
],
noticesSent: 2,
noticePending: 'URGENT: Notice 3',
nextStage: 'Debit Freeze in 3 days',
pendingActions: ['URGENT: Send Notice 3 NOW', 'Schedule Branch Visit immediately', 'Consider Video KYC as last resort'],
actionTaken: ['Notice 1 delivered', 'Notice 2 delivered via Reg. Post + IVR'],
evidence: null
}
};
alertRows: KmClient[] = [];
ngOnInit(): void {
this.alertRows = Object.values(this.clients);
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
this.activeSection = section;
}
openModal(id: string): void {
const c = this.clients[id];
if (!c) return;
this.current = c;
this.modalTab = 'overview';
this.modalOpen = true;
}
closeModal(): void {
this.modalOpen = false;
this.current = null;
}
showMTab(tab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action'): void {
this.modalTab = tab;
}
toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastVisible = false), 2800);
}
/** Compliance journey stepper state for a given client */
getStepperState(c: KmClient): { label: string; state: 'done' | 'pending' | 'current' | 'danger' }[] {
return [
{ label: 'KYC Active', state: 'done' },
{ label: 'Alert Sent', state: c.noticesSent >= 1 ? 'done' : 'pending' },
{ label: 'Notice 2', state: c.noticesSent >= 2 ? 'done' : c.noticesSent >= 1 ? 'current' : 'pending' },
{
label: 'Notice 3',
state:
c.noticesSent >= 3
? 'done'
: c.noticesSent >= 2
? c.state === 'urgent'
? 'danger'
: 'current'
: 'pending'
},
{
label: 'Debit Freeze',
state: ['freeze', 'inop', 'dormant'].includes(c.state) ? 'done' : c.noticesSent >= 3 ? 'danger' : 'pending'
},
{ label: 'Inoperative', state: ['inop', 'dormant'].includes(c.state) ? 'done' : 'pending' },
{ label: 'Dormant', state: c.state === 'dormant' ? 'danger' : 'pending' }
];
}
stepIcon(state: string): string {
if (state === 'done') return '✓';
if (state === 'danger') return '!';
return '·';
}
stateLabel(state: string): string {
return (
{
alert: 'Alert Window',
freeze: 'Debit Freeze',
inop: 'Inoperative',
dormant: 'Dormant',
urgent: 'Urgent Alert'
} as Record<string, string>
)[state] || state;
}
stateClass(state: string): string {
return (
{
alert: 'sp-al',
freeze: 'sp-fr',
inop: 'sp-in',
dormant: 'sp-do',
urgent: 'sp-fr'
} as Record<string, string>
)[state] || 'sp-al';
}
riskClass(risk: string): string {
return ({ low: 'b-gr', mid: 'b-am', high: 'b-re' } as Record<string, string>)[risk] || 'b-gy';
}
/** Notices pending count (cap at 0) */
noticesPending(c: KmClient): number {
return Math.max(0, 3 - c.noticesSent);
}
deliveryClass(d: string): string {
const m: Record<string, string> = {
Delivered: 'd-ok',
'In Transit': 'd-tr',
'No Response': 'd-fa',
Pending: 'd-pe',
'Visit Done': 'd-ok',
'Not Found': 'd-fa'
};
return m[d] || 'd-pe';
}
barWidth(c: KmClient): number {
if (c.daysLeft <= 0) return 100;
return Math.max(2, Math.min(100, c.daysLeft * 3));
}
barColor(c: KmClient): string {
if (c.daysLeft <= 0) return 'var(--km-g400)';
return c.daysLeft > 10 ? 'var(--km-amber)' : 'var(--km-red)';
}
responseClass(r: string): string {
const m: Record<string, string> = {
'No Action': 'd-pe',
'No Response': 'd-fa',
Pending: 'd-pe',
Refused: 'd-fa',
'Not Found at Address': 'd-fa',
Awaited: 'd-pe'
};
return m[r] || 'd-pe';
}
}
@@ -0,0 +1,22 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { KycManagementRoutingModule } from './kyc-management-routing.module';
import { KycManagementComponent } from './kyc-management.component';
@NgModule({
imports: [
SharedModule,
KycManagementRoutingModule,
KycManagementComponent
]
})
export class KycManagementModule {}
@@ -43,23 +43,23 @@ $text-muted: #7a96bc;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 42px; height: 42px;
background: rgba(255, 255, 255, 0.045); background: rgb(255 255 255 / 4.5%);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px; border-radius: 8px;
padding: 0 44px 0 38px; padding: 0 44px 0 38px;
color: #fff; color: #fff;
font-size: 0.82rem; font-size: 0.82rem;
font-family: 'Inter', sans-serif; font-family: Inter, sans-serif;
outline: none; outline: none;
transition: border-color 0.2s, background 0.2s; transition: border-color 0.2s, background 0.2s;
&:focus { &:focus {
border-color: $accent; border-color: $accent;
background: rgba(0, 180, 216, 0.07); background: rgb(0 180 216 / 7%);
} }
&::placeholder { &::placeholder {
color: rgba(255, 255, 255, 0.22); color: rgb(255 255 255 / 22%);
} }
} }
} }
@@ -106,9 +106,9 @@ $text-muted: #7a96bc;
.remember-checkbox { .remember-checkbox {
::ng-deep { ::ng-deep {
.mdc-checkbox .mdc-checkbox__native-control:enabled:not(:checked):not(:indeterminate):not([data-indeterminate='true']) .mdc-checkbox .mdc-checkbox__native-control:enabled:not(:checked, :indeterminate, [data-indeterminate='true'])
~ .mdc-checkbox__background { ~ .mdc-checkbox__background {
border-color: rgba(255, 255, 255, 0.4); border-color: rgb(255 255 255 / 40%);
} }
.mdc-checkbox .mdc-checkbox__native-control:enabled:checked ~ .mdc-checkbox__background { .mdc-checkbox .mdc-checkbox__native-control:enabled:checked ~ .mdc-checkbox__background {
@@ -117,7 +117,7 @@ $text-muted: #7a96bc;
} }
.mdc-label { .mdc-label {
color: rgba(255, 255, 255, 0.7); color: rgb(255 255 255 / 70%);
font-size: 0.82rem; font-size: 0.82rem;
} }
} }
@@ -130,7 +130,7 @@ $text-muted: #7a96bc;
::ng-deep { ::ng-deep {
.mdc-linear-progress__bar-inner { border-color: $accent; } .mdc-linear-progress__bar-inner { border-color: $accent; }
.mdc-linear-progress__buffer-bar { background-color: rgba(255, 255, 255, 0.1); } .mdc-linear-progress__buffer-bar { background-color: rgb(255 255 255 / 10%); }
} }
} }
@@ -140,7 +140,7 @@ $text-muted: #7a96bc;
} }
.btn-login { .btn-login {
-webkit-appearance: none; appearance: none;
appearance: none; appearance: none;
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
@@ -153,7 +153,7 @@ $text-muted: #7a96bc;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
color: #fff; color: #fff;
font-family: 'Inter', sans-serif; font-family: Inter, sans-serif;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -162,7 +162,7 @@ $text-muted: #7a96bc;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: opacity 0.2s, transform 0.1s; transition: opacity 0.2s, transform 0.1s;
box-shadow: 0 4px 20px rgba(0, 180, 216, 0.25); box-shadow: 0 4px 20px rgb(0 180 216 / 25%);
&:hover { opacity: 0.88; transform: translateY(-1px); } &:hover { opacity: 0.88; transform: translateY(-1px); }
&:active { transform: translateY(0); } &:active { transform: translateY(0); }
@@ -176,7 +176,7 @@ $text-muted: #7a96bc;
} }
// ═══ Responsive ═══ // ═══ Responsive ═══
@media (max-width: 480px) { @media (width <= 480px) {
.field-wrap input { font-size: 0.9rem; padding: 9px 12px 9px 36px; } .field-wrap input { font-size: 0.9rem; padding: 9px 12px 9px 36px; }
.btn-login { padding: 10px; font-size: 0.9rem; } .btn-login { padding: 10px; font-size: 0.9rem; }
} }
+47 -42
View File
@@ -22,7 +22,7 @@ $text-muted: #7a96bc;
&__dots-bg { &__dots-bg {
position: absolute; inset: 0; position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(0, 180, 216, 0.18) 1px, transparent 1px); background-image: radial-gradient(circle, rgb(0 180 216 / 18%) 1px, transparent 1px);
background-size: 34px 34px; opacity: 0.35; pointer-events: none; background-size: 34px 34px; opacity: 0.35; pointer-events: none;
} }
} }
@@ -57,13 +57,13 @@ $text-muted: #7a96bc;
&__icon { &__icon {
font-size: 3.4rem; margin-bottom: 14px; display: block; font-size: 3.4rem; margin-bottom: 14px; display: block;
filter: drop-shadow(0 0 14px rgba(0, 180, 216, 0.4)); line-height: 1; filter: drop-shadow(0 0 14px rgb(0 180 216 / 40%)); line-height: 1;
} }
&__title { &__title {
font-size: 2.55rem; font-weight: 700; line-height: 1.1; font-size: 2.55rem; font-weight: 700; line-height: 1.1;
margin-bottom: 14px; max-width: 500px; margin-bottom: 14px; max-width: 500px;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5); text-shadow: 0 2px 24px rgb(0 0 0 / 50%);
} }
&__desc { &__desc {
@@ -96,14 +96,15 @@ $text-muted: #7a96bc;
@include slide-theme('s1', #00b4d8); @include slide-theme('s1', #00b4d8);
@include slide-theme('s2', #34d05c); @include slide-theme('s2', #34d05c);
.s2 .slide__title { font-size: 2.1rem; } .s2 .slide__title { font-size: 2.1rem; }
@include slide-theme('s3', #ffcc55); @include slide-theme('s3', #ffcc55);
@include slide-theme('s4', #ff7070); @include slide-theme('s4', #ff7070);
@include slide-theme('s5', #b49eff); @include slide-theme('s5', #b49eff);
@include slide-theme('s6', #00e5b0); @include slide-theme('s6', #00e5b0);
.stat-chip { .stat-chip {
background: rgba(255, 255, 255, 0.04); background: rgb(255 255 255 / 4%);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgb(255 255 255 / 10%);
border-radius: 10px; padding: 10px 18px; border-radius: 10px; padding: 10px 18px;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
@@ -112,8 +113,8 @@ $text-muted: #7a96bc;
} }
.flow-node { .flow-node {
background: rgba(255, 255, 255, 0.04); background: rgb(255 255 255 / 4%);
border: 1px solid rgba(0, 180, 216, 0.25); border: 1px solid rgb(0 180 216 / 25%);
border-radius: 10px; padding: 9px 18px; border-radius: 10px; padding: 9px 18px;
font-size: 0.71rem; font-weight: 600; letter-spacing: 0.06em; font-size: 0.71rem; font-weight: 600; letter-spacing: 0.06em;
white-space: nowrap; text-align: center; white-space: nowrap; text-align: center;
@@ -122,12 +123,12 @@ $text-muted: #7a96bc;
.flow-arrow { .flow-arrow {
width: 1px; height: 20px; position: relative; width: 1px; height: 20px; position: relative;
background: linear-gradient(to bottom, rgba(0, 180, 216, 0.45), rgba(0, 180, 216, 0.05)); background: linear-gradient(to bottom, rgb(0 180 216 / 45%), rgb(0 180 216 / 5%));
&::after { &::after {
content: '▼'; position: absolute; bottom: -6px; left: 50%; content: '▼'; position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%); font-size: 0.38rem; transform: translateX(-50%); font-size: 0.38rem;
color: rgba(0, 180, 216, 0.45); color: rgb(0 180 216 / 45%);
} }
} }
@@ -139,7 +140,7 @@ $text-muted: #7a96bc;
.slide-dot { .slide-dot {
width: 7px; height: 7px; border-radius: 50%; width: 7px; height: 7px; border-radius: 50%;
background: rgba(255, 255, 255, 0.2); background: rgb(255 255 255 / 20%);
cursor: pointer; border: none; transition: all 0.3s; cursor: pointer; border: none; transition: all 0.3s;
&.active { background: $accent; width: 22px; border-radius: 4px; } &.active { background: $accent; width: 22px; border-radius: 4px; }
@@ -159,6 +160,7 @@ $text-muted: #7a96bc;
} }
@keyframes pbfill { from { width: 0%; } to { width: 100%; } } @keyframes pbfill { from { width: 0%; } to { width: 100%; } }
@keyframes dpulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.6); } } @keyframes dpulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.6); } }
// ═══ RIGHT PANEL — LOGIN ═══ // ═══ RIGHT PANEL — LOGIN ═══
@@ -168,12 +170,12 @@ $text-muted: #7a96bc;
justify-content: center; align-items: center; justify-content: center; align-items: center;
padding: 40px 48px; position: relative; overflow: hidden; padding: 40px 48px; position: relative; overflow: hidden;
background: linear-gradient(160deg, #07101f 0%, #0b1d35 65%, #081528 100%); background: linear-gradient(160deg, #07101f 0%, #0b1d35 65%, #081528 100%);
border-left: 1px solid rgba(0, 180, 216, 0.14); border-left: 1px solid rgb(0 180 216 / 14%);
&::before { &::before {
content: ''; position: absolute; top: -130px; right: -130px; content: ''; position: absolute; top: -130px; right: -130px;
width: 500px; height: 500px; border-radius: 50%; width: 500px; height: 500px; border-radius: 50%;
background: radial-gradient(circle, rgba(0, 180, 216, 0.07) 0%, transparent 65%); background: radial-gradient(circle, rgb(0 180 216 / 7%) 0%, transparent 65%);
pointer-events: none; pointer-events: none;
} }
} }
@@ -203,44 +205,44 @@ $text-muted: #7a96bc;
.right-footer { .right-footer {
position: absolute; bottom: 18px; left: 0; right: 0; position: absolute; bottom: 18px; left: 0; right: 0;
text-align: center; font-size: 0.61rem; text-align: center; font-size: 0.61rem;
color: rgba(255, 255, 255, 0.18); letter-spacing: 0.05em; color: rgb(255 255 255 / 18%); letter-spacing: 0.05em;
} }
// ═══ SETTINGS ═══ // ═══ SETTINGS ═══
.settings-btn { .settings-btn {
position: absolute; top: 16px; right: 16px; z-index: 100; position: absolute; top: 16px; right: 16px; z-index: 100;
width: 42px; height: 42px; border-radius: 50%; width: 42px; height: 42px; border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgb(255 255 255 / 20%);
background: rgba(255, 255, 255, 0.08); background: rgb(255 255 255 / 8%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
color: #fff; cursor: pointer; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.15); background: rgb(255 255 255 / 15%);
transform: rotate(45deg); transform: rotate(45deg);
} }
} }
.settings-overlay { .settings-overlay {
position: fixed; inset: 0; z-index: 99; position: fixed; inset: 0; z-index: 99;
background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(2px); background: rgb(0 0 0 / 30%); backdrop-filter: blur(2px);
} }
.settings-panel { .settings-panel {
position: absolute; top: 64px; right: 16px; z-index: 101; position: absolute; top: 64px; right: 16px; z-index: 101;
width: 320px; max-width: calc(100vw - 2rem); width: 320px; max-width: calc(100vw - 2rem);
background: rgba(255, 255, 255, 0.1); background: rgb(255 255 255 / 10%);
backdrop-filter: blur(24px) saturate(1.5); backdrop-filter: blur(24px) saturate(1.5);
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgb(255 255 255 / 15%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 32px rgb(0 0 0 / 30%);
overflow: hidden; overflow: hidden;
&__header { &__header {
padding: 16px 20px 10px; padding: 16px 20px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgb(255 255 255 / 10%);
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
h3 { margin: 0; font-size: 0.95rem; font-weight: 600; color: #fff; } h3 { margin: 0; font-size: 0.95rem; font-weight: 600; color: #fff; }
@@ -255,12 +257,12 @@ $text-muted: #7a96bc;
.settings-item { .settings-item {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); padding: 6px 0; border-bottom: 1px solid rgb(255 255 255 / 6%);
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
&__label { &__label {
font-size: 0.82rem; font-weight: 500; color: rgba(255, 255, 255, 0.85); font-size: 0.82rem; font-weight: 500; color: rgb(255 255 255 / 85%);
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
mifosx-m3-icon { font-size: 18px; opacity: 0.7; } mifosx-m3-icon { font-size: 18px; opacity: 0.7; }
@@ -273,13 +275,13 @@ $text-muted: #7a96bc;
#tenant-selector { #tenant-selector {
width: 100%; margin: 0; width: 100%; margin: 0;
.mat-mdc-text-field-wrapper { border-radius: 8px; background: rgba(255, 255, 255, 0.06); } .mat-mdc-text-field-wrapper { border-radius: 8px; background: rgb(255 255 255 / 6%); }
.mat-mdc-form-field-flex { height: 36px; } .mat-mdc-form-field-flex { height: 36px; }
.mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; } .mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; }
.mat-mdc-floating-label { color: rgba(255, 255, 255, 0.5); font-size: 0.8rem; } .mat-mdc-floating-label { color: rgb(255 255 255 / 50%); font-size: 0.8rem; }
.mat-mdc-select-value { color: #fff; font-size: 0.82rem; } .mat-mdc-select-value { color: #fff; font-size: 0.82rem; }
.mat-mdc-select-arrow { color: rgba(255, 255, 255, 0.6); } .mat-mdc-select-arrow { color: rgb(255 255 255 / 60%); }
.mat-mdc-form-field-icon-prefix { color: rgba(255, 255, 255, 0.4); padding: 0 6px 0 4px; } .mat-mdc-form-field-icon-prefix { color: rgb(255 255 255 / 40%); padding: 0 6px 0 4px; }
.mdc-line-ripple::after { border-bottom-color: $accent; } .mdc-line-ripple::after { border-bottom-color: $accent; }
} }
} }
@@ -292,13 +294,14 @@ $text-muted: #7a96bc;
width: 100%; margin: 0; width: 100%; margin: 0;
.mat-mdc-form-field-flex { height: 36px; } .mat-mdc-form-field-flex { height: 36px; }
.mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; } .mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; }
.mdc-notched-outline { .mdc-notched-outline {
.mdc-notched-outline__leading { border-radius: 8px 0 0 8px; border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__leading { border-radius: 8px 0 0 8px; border-color: rgb(255 255 255 / 20%); }
.mdc-notched-outline__notch { border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__notch { border-color: rgb(255 255 255 / 20%); }
.mdc-notched-outline__trailing { border-radius: 0 8px 8px 0; border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__trailing { border-radius: 0 8px 8px 0; border-color: rgb(255 255 255 / 20%); }
} }
.mat-mdc-select-value { color: #fff; } .mat-mdc-select-value { color: #fff; }
.mat-mdc-select-arrow { color: rgba(255, 255, 255, 0.6); } .mat-mdc-select-arrow { color: rgb(255 255 255 / 60%); }
} }
} }
@@ -310,47 +313,49 @@ $text-muted: #7a96bc;
width: 100%; margin: 0; width: 100%; margin: 0;
.mat-mdc-form-field-flex { height: 36px; } .mat-mdc-form-field-flex { height: 36px; }
.mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; } .mat-mdc-form-field-infix { padding: 6px 0; min-height: 36px; }
.mdc-notched-outline { .mdc-notched-outline {
.mdc-notched-outline__leading { border-radius: 8px 0 0 8px; border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__leading { border-radius: 8px 0 0 8px; border-color: rgb(255 255 255 / 20%); }
.mdc-notched-outline__notch { border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__notch { border-color: rgb(255 255 255 / 20%); }
.mdc-notched-outline__trailing { border-radius: 0 8px 8px 0; border-color: rgba(255, 255, 255, 0.2); } .mdc-notched-outline__trailing { border-radius: 0 8px 8px 0; border-color: rgb(255 255 255 / 20%); }
} }
.mat-mdc-select-value { color: #fff; } .mat-mdc-select-value { color: #fff; }
.mat-mdc-select-arrow { color: rgba(255, 255, 255, 0.6); } .mat-mdc-select-arrow { color: rgb(255 255 255 / 60%); }
} }
} }
// Theme toggle // Theme toggle
::ng-deep mifosx-theme-toggle { ::ng-deep mifosx-theme-toggle {
button { color: #fff; &:hover { background: rgba(255, 255, 255, 0.1); } } button { color: #fff; &:hover { background: rgb(255 255 255 / 10%); } }
} }
} }
// ═══ FOOTER ═══ // ═══ FOOTER ═══
.login-footer { .login-footer {
flex-shrink: 0; background: rgba(3, 8, 18, 0.97); flex-shrink: 0; background: rgb(3 8 18 / 97%);
border-top: 1px solid rgba(0, 180, 216, 0.17); border-top: 1px solid rgb(0 180 216 / 17%);
padding: 7px 32px; display: flex; align-items: center; justify-content: center; gap: 10px; padding: 7px 32px; display: flex; align-items: center; justify-content: center; gap: 10px;
&__text { &__text {
font-size: 0.61rem; color: $text-muted; letter-spacing: 0.03em; text-align: center; line-height: 1.5; font-size: 0.61rem; color: $text-muted; letter-spacing: 0.03em; text-align: center; line-height: 1.5;
strong { color: rgba(0, 180, 216, 0.9); font-weight: 600; } strong { color: rgb(0 180 216 / 90%); font-weight: 600; }
} }
&__sep { color: rgba(255, 255, 255, 0.12); font-size: 0.75rem; } &__sep { color: rgb(255 255 255 / 12%); font-size: 0.75rem; }
} }
// ═══ RESPONSIVE ═══ // ═══ RESPONSIVE ═══
@media (max-width: 1024px) { @media (width <= 1024px) {
.left-panel { flex: 0 0 55%; } .left-panel { flex: 0 0 55%; }
.right-panel { flex: 0 0 45%; padding: 32px; } .right-panel { flex: 0 0 45%; padding: 32px; }
.slide__title { font-size: 2rem; } .slide__title { font-size: 2rem; }
.slide__flow { display: none; } .slide__flow { display: none; }
} }
@media (max-width: 768px) { @media (width <= 768px) {
.login-split { flex-direction: column; } .login-split { flex-direction: column; }
.left-panel { display: none; } .left-panel { display: none; }
.right-panel { .right-panel {
flex: 1; padding: 24px; flex: 1; padding: 24px;
background: linear-gradient(160deg, #060f1e 0%, #0b1d35 100%); background: linear-gradient(160deg, #060f1e 0%, #0b1d35 100%);
@@ -43,7 +43,8 @@ $gray-900: #202124;
.caq-grid { .caq-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 16px; padding: 16px 24px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 16px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; }
@media (width <= 600px) { grid-template-columns: 1fr; }
} }
.caq-field { .caq-field {
@@ -62,7 +62,7 @@ $gray-900: #0f172a;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 12px; gap: 12px;
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -83,7 +83,7 @@ $gray-900: #0f172a;
&:hover { &:hover {
border-color: #cbd5e1; border-color: #cbd5e1;
background: $gray-50; background: $gray-50;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
.org-card__arrow { .org-card__arrow {
color: $gray-700; color: $gray-700;
@@ -102,62 +102,62 @@ $gray-900: #0f172a;
flex-shrink: 0; flex-shrink: 0;
&--blue { &--blue {
background: rgba(59, 130, 246, 0.1); background: rgb(59 130 246 / 10%);
color: #2563eb; color: #2563eb;
} }
&--amber { &--amber {
background: rgba(245, 158, 11, 0.1); background: rgb(245 158 11 / 10%);
color: #d97706; color: #d97706;
} }
&--green { &--green {
background: rgba(34, 197, 94, 0.1); background: rgb(34 197 94 / 10%);
color: #16a34a; color: #16a34a;
} }
&--emerald { &--emerald {
background: rgba(16, 185, 129, 0.1); background: rgb(16 185 129 / 10%);
color: #059669; color: #059669;
} }
&--purple { &--purple {
background: rgba(139, 92, 246, 0.1); background: rgb(139 92 246 / 10%);
color: #7c3aed; color: #7c3aed;
} }
&--indigo { &--indigo {
background: rgba(99, 102, 241, 0.1); background: rgb(99 102 241 / 10%);
color: #4f46e5; color: #4f46e5;
} }
&--slate { &--slate {
background: rgba(100, 116, 139, 0.1); background: rgb(100 116 139 / 10%);
color: #475569; color: #475569;
} }
&--orange { &--orange {
background: rgba(249, 115, 22, 0.1); background: rgb(249 115 22 / 10%);
color: #ea580c; color: #ea580c;
} }
&--teal { &--teal {
background: rgba(20, 184, 166, 0.1); background: rgb(20 184 166 / 10%);
color: #0d9488; color: #0d9488;
} }
&--cyan { &--cyan {
background: rgba(6, 182, 212, 0.1); background: rgb(6 182 212 / 10%);
color: #0891b2; color: #0891b2;
} }
&--rose { &--rose {
background: rgba(244, 63, 94, 0.1); background: rgb(244 63 94 / 10%);
color: #e11d48; color: #e11d48;
} }
&--red { &--red {
background: rgba(239, 68, 68, 0.1); background: rgb(239 68 68 / 10%);
color: #dc2626; color: #dc2626;
} }
} }
@@ -57,7 +57,7 @@ $gray-900: #202124;
border: 1px solid $border; border: 1px solid $border;
border-radius: 12px; border-radius: 12px;
padding: 0; padding: 0;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 1px 2px rgba(16, 24, 40, 0.04); box-shadow: 0 1px 3px rgb(16 24 40 / 6%), 0 1px 2px rgb(16 24 40 / 4%);
} }
.bl-section { .bl-section {
@@ -89,7 +89,7 @@ $gray-900: #202124;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 4px 20px; gap: 4px 20px;
@media (max-width: 600px) { @media (width <= 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -104,7 +104,7 @@ $gray-900: #202124;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
@media (max-width: 600px) { @media (width <= 600px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -184,7 +184,7 @@ $gray-900: #202124;
font-weight: 500; font-weight: 500;
font-size: 13px; font-size: 13px;
padding: 4px 20px !important; padding: 4px 20px !important;
box-shadow: 0 1px 2px rgba(19, 83, 160, 0.15); box-shadow: 0 1px 2px rgb(19 83 160 / 15%);
transition: all 0.2s ease; transition: all 0.2s ease;
i { i {
@@ -194,7 +194,7 @@ $gray-900: #202124;
&:hover { &:hover {
background: #0f4688 !important; background: #0f4688 !important;
box-shadow: 0 2px 6px rgba(19, 83, 160, 0.25); box-shadow: 0 2px 6px rgb(19 83 160 / 25%);
} }
&:disabled { &:disabled {
@@ -73,7 +73,7 @@ $gray-900: #202124;
justify-content: space-between; justify-content: space-between;
padding: 10px 14px; padding: 10px 14px;
background: $gray-50; background: $gray-50;
border: 1px solid rgba(0, 0, 0, 0.04); border: 1px solid rgb(0 0 0 / 4%);
border-radius: 8px; border-radius: 8px;
margin-bottom: 6px; margin-bottom: 6px;
transition: background 0.12s; transition: background 0.12s;
@@ -110,7 +110,7 @@ $gray-900: #202124;
line-height: 32px !important; line-height: 32px !important;
font-size: 13px; font-size: 13px;
&:hover { background: rgba(239, 68, 68, 0.08) !important; } &:hover { background: rgb(239 68 68 / 8%) !important; }
} }
} }
@@ -92,7 +92,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ce-field { .ce-field {
@@ -92,7 +92,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ee-field { .ee-field {
@@ -81,14 +81,14 @@ $gray-900: #202124;
padding: 20px 28px 24px; padding: 20px 28px 24px;
background: #fff; background: #fff;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ve-cell { .ve-cell {
padding: 14px 16px; padding: 14px 16px;
background: #f8fafc; background: #f8fafc;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04); border: 1px solid rgb(0 0 0 / 4%);
margin: 3px; margin: 3px;
transition: background 0.12s; transition: background 0.12s;
@@ -124,12 +124,12 @@ $gray-900: #202124;
letter-spacing: 0.2px; letter-spacing: 0.2px;
&--active { &--active {
background: rgba(22, 163, 74, 0.1); background: rgb(22 163 74 / 10%);
color: #16a34a; color: #16a34a;
} }
&--inactive { &--inactive {
background: rgba(220, 38, 38, 0.1); background: rgb(220 38 38 / 10%);
color: #dc2626; color: #dc2626;
} }
} }
@@ -88,7 +88,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ce-field { .ce-field {
@@ -96,7 +96,7 @@ $gray-900: #202124;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
&--3col { grid-template-columns: 1fr; } &--3col { grid-template-columns: 1fr; }
@@ -127,7 +127,7 @@ $gray-900: #202124;
padding: 0 0 8px; padding: 0 0 8px;
margin-top: -4px; margin-top: -4px;
@media (max-width: 768px) { @media (width <= 768px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -92,7 +92,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ch-field { .ch-field {
@@ -92,7 +92,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.eh-field { .eh-field {
@@ -85,14 +85,14 @@ $gray-900: #202124;
gap: 10px; gap: 10px;
padding: 20px 24px; padding: 20px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.vh-cell { .vh-cell {
padding: 14px 16px; padding: 14px 16px;
background: $gray-50; background: $gray-50;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04); border: 1px solid rgb(0 0 0 / 4%);
transition: background 0.12s; transition: background 0.12s;
&:hover { &:hover {
@@ -130,7 +130,7 @@ $gray-900: #202124;
} }
// Responsive // Responsive
@media (max-width: 600px) { @media (width <= 600px) {
.vh-header { .vh-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -92,8 +92,9 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 768px) { grid-template-columns: 1fr; } @media (width <= 768px) { grid-template-columns: 1fr; }
@media (max-width: 1024px) and (min-width: 769px) { grid-template-columns: repeat(2, 1fr); }
@media (width <= 1024px) and (width >= 769px) { grid-template-columns: repeat(2, 1fr); }
} }
.inv-field { .inv-field {
@@ -105,7 +106,7 @@ $gray-900: #202124;
align-items: center; align-items: center;
padding-top: 4px; padding-top: 4px;
@media (max-width: 768px) { padding-top: 0; } @media (width <= 768px) { padding-top: 0; }
} }
.inv-search-btn { .inv-search-btn {
@@ -210,7 +211,7 @@ $gray-900: #202124;
gap: 16px; gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
&__item { &__item {
@@ -84,7 +84,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.cl-field { .cl-field {
@@ -84,7 +84,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.el-field { .el-field {
@@ -98,7 +98,7 @@ $gray-900: #202124;
gap: 16px; gap: 16px;
padding: 20px 24px; padding: 20px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.vl-detail { .vl-detail {
@@ -94,7 +94,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.cp-field { .cp-field {
@@ -94,7 +94,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ep-field { .ep-field {
@@ -84,7 +84,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.cf-field { .cf-field {
@@ -84,7 +84,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.ef-field { .ef-field {
@@ -98,7 +98,7 @@ $gray-900: #202124;
gap: 16px; gap: 16px;
padding: 20px 24px; padding: 20px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.vf-detail { .vf-detail {
@@ -92,7 +92,7 @@ $gray-900: #202124;
gap: 4px 16px; gap: 4px 16px;
padding: 16px 24px; padding: 16px 24px;
@media (max-width: 600px) { grid-template-columns: 1fr; } @media (width <= 600px) { grid-template-columns: 1fr; }
} }
.co-field { .co-field {

Some files were not shown because too many files have changed in this diff Show More