-
-
{{ 'labels.heading.CKYC' | translate }}
-
{{ 'labels.text.This section is under development.' | translate }}
+
+
+
+
+
+
+
+
+
+
Total batches
+
{{ totalBatches }}
+
+
+
+
+
+
Total records
+
{{ totalRecords }}
+
+
+
+
+
+
Pending upload
+
{{ totalPending }}
+
+
+
+
+
+
Uploaded
+
{{ totalUploaded }}
+
+
+
+
+
+
Total size
+
{{ formatBytes(totalSize) }}
+
+
+
+
+
+
+
+
+ From date
+
+
+
+ To date
+
+
+
+ Upload status
+
+ All batches
+ Pending upload
+ Uploaded
+
+
+
+
+
+ Reset
+
+
+ Apply filters
+
+
+
+
+
+
+
+
+ ZIP Batch Details
+ Phase 2
+
+
+
+ SFTP Upload & ACK · API Match
+ @if (totalPending > 0) {
+ {{ totalPending }}
+ }
+ Phase 3-4
+
+
+
+
+ @if (activeSection === 'zip') {
+
+
+
+
+
+
ZIP Batch Details
+
CERSAI ZIP archives grouped by creation date
+
+
+
Phase 2
+
+
+ @if (loading) {
+
+
+ Loading batches…
+
+ }
+
+ @if (error && !loading) {
+
+
+ {{ error }}
+
+ }
+
+ @if (!loading && !error && groups.length === 0) {
+
+
+
No batches found
+
Create a batch from the Record Master to see it here.
+
+ }
+
+ @if (!loading && groups.length > 0) {
+
+ @for (group of groups; track group.dateLabel) {
+
+
+
+
+
+
+ {{ group.dateLabel }}
+
+
+ {{ group.batchCount }} batches
+ ·
+ {{ group.totalRecordCount }} records
+ ·
+ {{ formatBytes(group.totalSizeBytes) }}
+
+
+
+ @if (group.pendingCount > 0) {
+
+ {{ group.pendingCount }} pending
+
+ }
+ @if (group.uploadedCount > 0) {
+
+ {{ group.uploadedCount }} uploaded
+
+ }
+
+
+
+ @if (group.expanded) {
+
+
+
+
+ Batch ID
+ Filename
+ Records
+ Size
+ Status
+ Actions
+
+
+
+ @for (batch of group.batches; track trackBatch($index, batch)) {
+
+
+
+ {{ batch.batchId }}
+
+
+
+ {{ batch.filename }}
+
+ {{ batch.recordCount }}
+ {{ formatBytes(batch.sizeBytes) }}
+
+ @if (batch.uploadedToCersai) {
+
+ Uploaded
+
+ } @else {
+
+ Pending
+
+ }
+
+
+
+
+
+ @if (batch.documentId) {
+
+
+
+ }
+
+
+ }
+
+
+
+ }
+
+ }
+
+ }
+
+
+ }
+
+
+ @if (activeSection === 'sftp') {
+
+
+
+
+
+
SFTP Upload & ACK · API Match
+
CERSAI SFTP transfer history with ACK receipts and per-batch logs
+
+
+
Phase 3-4
+
+
+
+
+
+
+
{{ sftpSuccessCount }}
+
Uploaded successfully
+
+
+
+
+
+
{{ sftpFailedCount }}
+
Failed
+
+
+
+
+
+
{{ sftpPendingCount }}
+
Pending upload
+
+
+
+
+ @if (loading) {
+
+
+ Loading logs…
+
+ }
+
+ @if (!loading && sftpLogs.length === 0) {
+
+
+
No SFTP upload history yet
+
Upload a batch from the Record Master to see CERSAI transfer logs here.
+
+ }
+
+ @if (!loading && sftpLogs.length > 0) {
+
+ @for (log of sftpLogs; track log.id) {
+
+
+ @if (isSftpSuccess(log)) {
+
+ } @else if (isSftpFailed(log)) {
+
+ } @else {
+
+ }
+
+
+
+
+ {{ log.batchId }}
+
+ @if (isSftpSuccess(log)) {
+
+ Upload successful
+
+ } @else if (isSftpFailed(log)) {
+
+ Upload failed
+
+ } @else {
+
+ Pending
+
+ }
+
+
+ {{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
+
+
+
+
+
+ @if (log.uploadRemarks) {
+
+ }
+
+
+
+
+
+
+ @if (log.documentId) {
+
+
+
+ }
+
+
+ }
+
+ }
+
+ }
+
+
+ @if (showManifest) {
+
+
+
+
+
+ @if (manifestLoading) {
+
+
+ Loading manifest…
+
+ }
+
+ @if (manifestError) {
+
+
+ {{ manifestError }}
+
+ }
+
+ @if (manifestBatch && !manifestLoading) {
+
+
+
+ Batch ID
+ {{ manifestBatch.batchId }}
+
+
+ Created on
+ {{ manifestBatch.createdOnLabel || '—' }}
+
+
+ Records
+ {{ manifestBatch.recordCount }}
+
+
+ Size
+ {{ formatBytes(manifestBatch.sizeBytes) }}
+
+
+ Status
+ @if (manifestBatch.uploadedToCersai) {
+
+ Uploaded
+
+ } @else {
+
+ Pending
+
+ }
+
+
+ Filename
+ {{ manifestBatch.filename }}
+
+
+ Checksum
+ {{ manifestBatch.checksum }}
+
+
+
+
+ @if (manifestBatch.manifest?.length > 0) {
+
+
+ Packaged clients ({{ manifestBatch.manifest.length }})
+
+
+
+
+
+ Client
+ P
+ S
+ ID
+ Ad
+ Total size
+
+
+
+ @for (entry of manifestBatch.manifest; track entry.clientId) {
+
+
+
+ {{ entry.displayName || 'Client ' + entry.clientId }}
+ @if (entry.cifNumber) {
+
+ {{ entry.cifNumber }}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
+
+
+ }
+
+
+
+ }
+ }
+
+
+
+
+
+ }
diff --git a/src/app/ckyc/ckyc-verification/ckyc-verification.component.scss b/src/app/ckyc/ckyc-verification/ckyc-verification.component.scss
new file mode 100644
index 000000000..50441b582
--- /dev/null
+++ b/src/app/ckyc/ckyc-verification/ckyc-verification.component.scss
@@ -0,0 +1,1167 @@
+/**
+ * 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/.
+ */
+
+$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;
+$green: #1d9e75;
+$green-light: #e1f5ee;
+$teal-900: #085041;
+$teal-700: #0f6e56;
+$red: #d85a30;
+$red-light: #faece7;
+$amber: #ba7517;
+$amber-light: #faeeda;
+$purple: #6a4cb3;
+$purple-light: #efeafb;
+
+.cv-container {
+ padding: 22px 26px 40px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+/* ---------- Header ---------- */
+.cv-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 18px;
+ flex-wrap: wrap;
+ gap: 12px;
+
+ &__left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ }
+
+ &__icon {
+ width: 44px;
+ height: 44px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, $teal-700, $green);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ box-shadow: 0 3px 10px rgba($green, 0.3);
+ flex-shrink: 0;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ &__sub {
+ margin: 2px 0 0;
+ font-size: 13px;
+ color: $gray-500;
+ }
+}
+
+.cv-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
+ height: 38px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ border: 1px solid $gray-200;
+ background: $white;
+ color: $gray-700;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ fa-icon { font-size: 12px; }
+
+ &:hover:not(:disabled) {
+ background: $gray-50;
+ border-color: $gray-500;
+ }
+
+ &--ghost { /* default */ }
+
+ &--primary {
+ background: $primary;
+ border-color: $primary;
+ color: $white;
+
+ &:hover:not(:disabled) {
+ background: #1557b5;
+ border-color: #1557b5;
+ }
+ }
+}
+
+/* ---------- KPIs ---------- */
+.cv-kpis {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 18px;
+}
+
+.cv-kpi {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 12px;
+
+ &__icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ flex-shrink: 0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+ &--blue { background: linear-gradient(135deg, $primary, #4285f4); }
+ &--teal { background: linear-gradient(135deg, $teal-700, $green); }
+ &--amber { background: linear-gradient(135deg, $amber, #d4892f); }
+ &--green { background: linear-gradient(135deg, $green, #2fb88c); }
+ &--purple { background: linear-gradient(135deg, $purple, #8a6fd4); }
+ }
+
+ &__label {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: $gray-500;
+ margin-bottom: 2px;
+ }
+
+ &__value {
+ font-size: 18px;
+ font-weight: 700;
+ color: $gray-900;
+
+ &--mono {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 13px;
+ color: $primary;
+ }
+ }
+}
+
+/* ---------- Filters ---------- */
+.cv-filters {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 12px;
+ padding: 18px 20px;
+ margin-bottom: 18px;
+
+ &__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px 18px;
+ margin-bottom: 16px;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 14px;
+ border-top: 1px dashed $gray-200;
+ }
+}
+
+.cv-field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+
+.cv-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-700;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: $primary; font-size: 11px; }
+}
+
+.cv-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;
+ height: 40px;
+ box-sizing: border-box;
+ transition: all 0.15s;
+
+ &:focus {
+ outline: none;
+ border-color: $primary;
+ background: $white;
+ box-shadow: 0 0 0 3px rgba($primary, 0.1);
+ }
+}
+
+select.cv-input {
+ appearance: none;
+ padding-right: 34px;
+ background-image: url("data:image/svg+xml;utf8,
");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ cursor: pointer;
+}
+
+/* ---------- Section tabs ---------- */
+.cv-tabs {
+ display: flex !important;
+ gap: 10px !important;
+ margin-bottom: 16px !important;
+ background: $white !important;
+ border: 1px solid $gray-200 !important;
+ border-radius: 14px !important;
+ padding: 8px !important;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) !important;
+}
+
+button.cv-tab,
+.cv-tabs .cv-tab {
+ flex: 1 1 0 !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 10px !important;
+ padding: 14px 20px !important;
+ min-height: 52px !important;
+ border: 1.5px solid transparent !important;
+ border-radius: 10px !important;
+ background: transparent !important;
+ color: $gray-700 !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ cursor: pointer !important;
+ transition: all 0.15s !important;
+ font-family: inherit !important;
+ white-space: nowrap !important;
+ min-width: 0 !important;
+ line-height: 1 !important;
+ outline: none !important;
+ box-shadow: none !important;
+
+ > fa-icon {
+ font-size: 15px !important;
+ color: $gray-500 !important;
+ }
+
+ > span:not(.cv-tab__badge):not(.cv-tab__count) {
+ display: inline-flex !important;
+ align-items: center !important;
+ }
+
+ &:hover {
+ background: $gray-50 !important;
+ color: $gray-900 !important;
+
+ > fa-icon { color: $primary !important; }
+ }
+
+ &--active {
+ background: $primary-light !important;
+ border-color: rgba($primary, 0.35) !important;
+ color: $primary-dark !important;
+ box-shadow: 0 2px 8px rgba($primary, 0.12) !important;
+
+ > fa-icon { color: $primary !important; }
+ }
+}
+
+.cv-tab__badge {
+ display: inline-block !important;
+ padding: 3px 9px !important;
+ border-radius: 10px !important;
+ font-size: 9px !important;
+ font-weight: 700 !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.3px !important;
+ margin-left: 2px !important;
+
+ &--blue {
+ background: $primary-light !important;
+ color: $primary-dark !important;
+ }
+
+ &--amber {
+ background: $amber-light !important;
+ color: $amber !important;
+ }
+}
+
+.cv-tab__count {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-width: 22px !important;
+ height: 20px !important;
+ padding: 0 8px !important;
+ border-radius: 10px !important;
+ background: $amber !important;
+ color: $white !important;
+ font-size: 10px !important;
+ font-weight: 700 !important;
+ font-variant-numeric: tabular-nums !important;
+}
+
+@media (width <= 680px) {
+ .cv-tabs { flex-direction: column !important; }
+ button.cv-tab,
+ .cv-tabs .cv-tab { justify-content: space-between !important; }
+}
+
+/* Hide the per-section header badge since the tab strip already carries it */
+.cv-section .cv-badge { display: none; }
+
+/* ---------- Section card ---------- */
+.cv-section {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 14px;
+ padding: 18px 20px 20px;
+ margin-bottom: 18px;
+
+ &__head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid $gray-100;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+
+ > fa-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: $primary-light;
+ color: $primary;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ }
+
+ h3 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ p {
+ margin: 2px 0 0;
+ font-size: 11px;
+ color: $gray-500;
+ }
+ }
+
+ &--placeholder {
+ background: linear-gradient(135deg, $gray-50, $primary-light);
+ }
+}
+
+.cv-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+
+ &--blue { background: $primary-light; color: $primary-dark; }
+ &--amber { background: $amber-light; color: $amber; }
+}
+
+/* ---------- State banners ---------- */
+.cv-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 30px 20px;
+ border-radius: 10px;
+ font-size: 13px;
+
+ fa-icon { font-size: 18px; }
+
+ &--loading {
+ background: $primary-light;
+ color: $primary;
+ border: 1px dashed rgba($primary, 0.3);
+ }
+
+ &--error {
+ background: $red-light;
+ color: $red;
+ border: 1px solid rgba($red, 0.25);
+ }
+
+ &--empty {
+ background: $gray-50;
+ color: $gray-500;
+ border: 1px dashed $gray-200;
+
+ fa-icon { color: $primary; margin-bottom: 4px; }
+ h4 { margin: 0; font-size: 15px; font-weight: 600; color: $gray-900; }
+ p { margin: 0; font-size: 12px; }
+ }
+}
+
+/* ---------- Batch groups ---------- */
+.cv-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.cv-group {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+ overflow: hidden;
+ transition: border-color 0.15s, box-shadow 0.15s;
+
+ &:hover { border-color: rgba($primary, 0.3); }
+
+ &--open {
+ border-color: $primary-light;
+ box-shadow: 0 2px 10px rgba($primary, 0.05);
+ }
+
+ &__head {
+ width: 100%;
+ padding: 14px 18px;
+ background: $gray-50;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ cursor: pointer;
+ font-family: inherit;
+ text-align: left;
+
+ &:hover { background: $gray-100; }
+ }
+
+ &__left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__chev {
+ color: $primary;
+ font-size: 14px;
+ flex-shrink: 0;
+ }
+
+ &__date-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ background: $primary-light;
+ color: $primary-dark;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 700;
+
+ fa-icon { color: $primary; font-size: 11px; }
+ }
+
+ &__summary {
+ font-size: 12px;
+ color: $gray-700;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+
+ strong { color: $gray-900; font-weight: 700; }
+ }
+
+ &__sep { color: $gray-200; }
+
+ &__right {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+ }
+
+ &__body {
+ background: $white;
+ border-top: 1px solid $gray-200;
+ overflow-x: auto;
+ }
+}
+
+.cv-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 700;
+
+ fa-icon { font-size: 10px; }
+
+ &--green {
+ background: $green-light;
+ color: $teal-900;
+ border: 1px solid rgba($green, 0.25);
+ }
+
+ &--amber {
+ background: $amber-light;
+ color: $amber;
+ border: 1px solid rgba($amber, 0.3);
+ }
+
+ &--red {
+ background: $red-light;
+ color: $red;
+ border: 1px solid rgba($red, 0.3);
+ }
+}
+
+/* ---------- SFTP logs ---------- */
+.cv-sftp-kpis {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 12px;
+ padding: 16px 20px 4px;
+}
+
+.cv-sftp-kpi {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ border-radius: 12px;
+ border: 1px solid $gray-200;
+ background: $white;
+
+ &__icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ flex-shrink: 0;
+ }
+
+ &__value {
+ font-size: 20px;
+ font-weight: 700;
+ color: $gray-900;
+ line-height: 1.1;
+ }
+
+ &__label {
+ font-size: 11px;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ margin-top: 2px;
+ }
+
+ &--green {
+ border-color: rgba($green, 0.3);
+ background: linear-gradient(180deg, $green-light, $white);
+ .cv-sftp-kpi__icon { background: $green; color: $white; }
+ }
+
+ &--red {
+ border-color: rgba($red, 0.3);
+ background: linear-gradient(180deg, $red-light, $white);
+ .cv-sftp-kpi__icon { background: $red; color: $white; }
+ }
+
+ &--amber {
+ border-color: rgba($amber, 0.3);
+ background: linear-gradient(180deg, $amber-light, $white);
+ .cv-sftp-kpi__icon { background: $amber; color: $white; }
+ }
+}
+
+.cv-sftp-logs {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 20px 20px;
+}
+
+.cv-sftp-log {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 14px 16px;
+ border: 1px solid $gray-200;
+ border-left-width: 4px;
+ border-radius: 10px;
+ background: $white;
+ transition: background 0.15s, box-shadow 0.15s;
+
+ &:hover { background: #fafbfc; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); }
+
+ &--success { border-left-color: $green; }
+ &--failed { border-left-color: $red; }
+
+ &__icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: $gray-100;
+ color: $gray-500;
+ font-size: 16px;
+ flex-shrink: 0;
+ }
+
+ &--success .cv-sftp-log__icon { background: $green-light; color: $green; }
+ &--failed .cv-sftp-log__icon { background: $red-light; color: $red; }
+
+ &__body { flex: 1; min-width: 0; }
+
+ &__head {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 8px;
+ }
+
+ &__bid {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 13px;
+ font-weight: 700;
+ color: $primary;
+
+ fa-icon { font-size: 11px; margin-right: 4px; }
+ }
+
+ &__time {
+ font-size: 11px;
+ color: $gray-500;
+ margin-left: auto;
+
+ fa-icon { font-size: 10px; margin-right: 4px; }
+ }
+
+ &__meta {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 8px 16px;
+ font-size: 12px;
+
+ > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+ }
+
+ &__label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: $gray-500;
+ font-weight: 600;
+ }
+
+ &__value {
+ color: $gray-900;
+ font-weight: 500;
+ word-break: break-all;
+
+ &--mono {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ }
+ }
+
+ &__remarks {
+ margin-top: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: $gray-50;
+ border: 1px solid $gray-200;
+ font-size: 12px;
+ color: $gray-700;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+
+ fa-icon { font-size: 12px; color: $gray-500; margin-top: 2px; }
+
+ &--err {
+ background: $red-light;
+ border-color: rgba($red, 0.3);
+ color: $red;
+
+ fa-icon { color: $red; }
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ }
+}
+
+/* ---------- Batches table ---------- */
+.cv-batches {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ thead tr { background: $gray-50; }
+
+ th {
+ text-align: left;
+ padding: 10px 14px;
+ font-size: 10px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 12px 14px;
+ border-top: 1px solid $gray-100;
+ color: $gray-900;
+ vertical-align: middle;
+ }
+
+ tbody tr:hover { background: #f5f8fc; }
+
+ &__num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+ &__actions { text-align: right; width: 100px; }
+
+ &__bid {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 12px;
+ font-weight: 600;
+ color: $primary;
+ white-space: nowrap;
+
+ fa-icon { color: $primary; margin-right: 6px; font-size: 11px; }
+ }
+
+ &__file {
+ color: $gray-700;
+
+ fa-icon { color: $primary; margin-right: 6px; font-size: 11px; }
+
+ span {
+ word-break: break-all;
+ max-width: 280px;
+ display: inline-block;
+ vertical-align: middle;
+ }
+ }
+}
+
+.cv-action-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid $gray-200;
+ background: $white;
+ color: $gray-700;
+ cursor: pointer;
+ margin-left: 4px;
+ transition: all 0.15s;
+
+ fa-icon { font-size: 12px; }
+
+ &:hover {
+ background: $primary-light;
+ color: $primary;
+ border-color: $primary;
+ }
+
+ &--primary {
+ background: $primary;
+ color: $white;
+ border-color: $primary;
+
+ &:hover {
+ background: #1557b5;
+ color: $white;
+ }
+ }
+}
+
+/* ---------- SFTP placeholder ---------- */
+.cv-placeholder {
+ padding: 30px 24px;
+ text-align: center;
+
+ &__icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 16px;
+ background: $white;
+ border: 1px solid $primary-light;
+ color: $primary;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28px;
+ margin: 0 auto 12px;
+ box-shadow: 0 4px 14px rgba($primary, 0.1);
+ }
+
+ h4 {
+ margin: 0 0 6px;
+ font-size: 15px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ p {
+ margin: 0 auto 14px;
+ font-size: 12px;
+ color: $gray-700;
+ max-width: 480px;
+ line-height: 1.55;
+ }
+
+ &__tags {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+}
+
+.cv-chip {
+ padding: 5px 12px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 16px;
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-700;
+}
+
+/* ---------- Manifest modal ---------- */
+.cv-modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 25, 45, 0.55);
+ backdrop-filter: blur(3px);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+
+ &__card {
+ background: $white;
+ border-radius: 16px;
+ width: 100%;
+ max-width: 720px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 22px;
+ border-bottom: 1px solid $gray-100;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ font-weight: 700;
+ color: $gray-900;
+
+ fa-icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, $primary, #4285f4);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ box-shadow: 0 3px 10px rgba($primary, 0.3);
+ }
+ }
+
+ &__close {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid $gray-200;
+ background: $white;
+ color: $gray-500;
+ cursor: pointer;
+
+ &:hover {
+ color: $red;
+ border-color: $red;
+ background: $red-light;
+ }
+ }
+
+ &__body {
+ padding: 20px 22px;
+ overflow-y: auto;
+ flex: 1;
+ }
+
+ &__footer {
+ padding: 16px 22px;
+ border-top: 1px solid $gray-100;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ background: $gray-50;
+ }
+}
+
+.cv-manifest-head {
+ background: linear-gradient(135deg, $primary-light, $green-light);
+ border: 1px solid rgba($primary, 0.12);
+ border-radius: 10px;
+ padding: 16px 18px;
+ margin-bottom: 16px;
+
+ &__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px 20px;
+ }
+
+ &__grid-span {
+ grid-column: 1 / -1;
+ }
+
+ /* Force stacked layout inside each field */
+ > .cv-manifest-head__grid > div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+
+ .cv-kpi__label {
+ display: block;
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: $teal-700;
+ opacity: 0.85;
+ margin: 0;
+ }
+
+ .cv-kpi__value {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ color: $gray-900;
+ word-break: break-word;
+ line-height: 1.35;
+
+ &--mono {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ font-size: 11px;
+ color: $primary-dark;
+ letter-spacing: 0.2px;
+ word-break: break-all;
+ }
+ }
+
+ .cv-pill {
+ align-self: flex-start;
+ }
+ }
+}
+
+.cv-manifest-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin: 10px 0 8px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: $primary; font-size: 12px; }
+}
+
+.cv-manifest-table-wrap {
+ max-height: 320px;
+ overflow-y: auto;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+}
+
+.cv-manifest-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+
+ thead tr {
+ background: $gray-50;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 9px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ white-space: nowrap;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ td {
+ padding: 8px 12px;
+ border-bottom: 1px solid $gray-100;
+ vertical-align: middle;
+ }
+
+ tbody tr:last-child td { border-bottom: none; }
+ tbody tr:hover { background: #f5f8fc; }
+}
+
+.cv-th-c { text-align: center !important; width: 38px; }
+.cv-th-r { text-align: right !important; }
+.cv-td-c { text-align: center; }
+.cv-td-r { text-align: right; }
+
+.cv-manifest-client {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ strong {
+ font-size: 12px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+}
+
+.cv-manifest-cif {
+ font-size: 10px;
+ color: $gray-500;
+ font-family: 'SF Mono', Consolas, monospace;
+
+ fa-icon { font-size: 8px; margin-right: 2px; }
+}
+
+.cv-dot {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ font-size: 9px;
+ font-weight: 700;
+
+ &--ok {
+ background: $green-light;
+ color: $green;
+ border: 1px solid rgba($green, 0.3);
+ }
+
+ &--miss {
+ background: $red-light;
+ color: $red;
+ border: 1px solid rgba($red, 0.3);
+ }
+}
+
+.cv-manifest-size {
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-700;
+ font-family: 'SF Mono', Consolas, monospace;
+ white-space: nowrap;
+}
+
+.cv-manifest-row--complete {
+ background: rgba($green, 0.03);
+}
+
+/* ---------- Responsive ---------- */
+@media (width <= 1100px) {
+ .cv-kpis { grid-template-columns: repeat(3, 1fr); }
+}
+
+@media (width <= 700px) {
+ .cv-kpis { grid-template-columns: repeat(2, 1fr); }
+ .cv-filters__grid { grid-template-columns: 1fr; }
+ .cv-manifest-head__grid { grid-template-columns: 1fr; }
+}
diff --git a/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts b/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts
index 8d62b9d5c..e0ae34e81 100644
--- a/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts
+++ b/src/app/ckyc/ckyc-verification/ckyc-verification.component.ts
@@ -6,16 +6,297 @@
* 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 { 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({
selector: 'mifosx-ckyc-verification',
templateUrl: './ckyc-verification.component.html',
+ styleUrls: ['./ckyc-verification.component.scss'],
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
+ );
+ }
+}
diff --git a/src/app/ckyc/ckyc.component.html b/src/app/ckyc/ckyc.component.html
index 597ecb322..e90a7daec 100644
--- a/src/app/ckyc/ckyc.component.html
+++ b/src/app/ckyc/ckyc.component.html
@@ -6,120 +6,145 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
CKYC Compliance Centre
+
+ Central KYC registry management — collect, upload and verify client artefacts against CERSAI
+
-
+
+
+ CERSAI ready
+
+
+
+
+
+
+
+ 5-phase compliance pipeline
+
+ CBS → CKYC automation flow
+
+
+
+
+
1
+
+
Mark Non-CKYC
+
Identify missing CKYC registrations
+
+
+
+
+
2
+
+
Auto ZIP
+
Bundle into CERSAI format
+
+
+
+
+
3 · 4
+
+
SFTP Upload & API Match
+
Transfer · ACK · Resolve CKYC numbers
+
+
+
+
+
5
+
+
Status Update
+
Update master & MIS
+
+
+
+
+
+
+ Quick actions
+
+
+
diff --git a/src/app/ckyc/ckyc.component.scss b/src/app/ckyc/ckyc.component.scss
index af9f4c075..3ade750b6 100644
--- a/src/app/ckyc/ckyc.component.scss
+++ b/src/app/ckyc/ckyc.component.scss
@@ -5,3 +5,465 @@
* 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/.
*/
+
+$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; }
+}
diff --git a/src/app/ckyc/ckyc.component.ts b/src/app/ckyc/ckyc.component.ts
index 315569c26..521a90b25 100644
--- a/src/app/ckyc/ckyc.component.ts
+++ b/src/app/ckyc/ckyc.component.ts
@@ -7,11 +7,7 @@
*/
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 { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -20,17 +16,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./ckyc.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
- MatNavList,
- MatListItem,
- MatIcon,
- FaIconComponent,
- MatLine
+ FaIconComponent
]
})
-export class CkycComponent {
- arrowBooleans: boolean[] = new Array(4).fill(false);
-
- arrowBooleansToggle(arrowNumber: number) {
- this.arrowBooleans[arrowNumber] = !this.arrowBooleans[arrowNumber];
- }
-}
+export class CkycComponent {}
diff --git a/src/app/ckyc/record-master/record-master.component.html b/src/app/ckyc/record-master/record-master.component.html
index c7ad688fc..c37e72f77 100644
--- a/src/app/ckyc/record-master/record-master.component.html
+++ b/src/app/ckyc/record-master/record-master.component.html
@@ -6,14 +6,943 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
-
-
{{ 'labels.heading.Record Master' | translate }}
-
{{ 'labels.text.This section is under development.' | translate }}
+
+
+
+
+
+
+
+
+ All clients
+ {{ tabCounts.all }}
+
+
+
+ Pending collection
+ {{ tabCounts.pendingCollection }}
+
+
+
+ Ready for CERSAI
+ {{ tabCounts.ready }}
+
+
+
+
+
+
+
+ @if (loading) {
+
+
+ Loading CKYC records…
+
+ }
+
+ @if (error && !loading) {
+
+
+ {{ error }}
+
+ }
+
+ @if (!loading && !error && rows.length === 0) {
+
+
+
No records found
+
All clients matching your filters have complete CKYC artefacts. 🎉
+
+ }
+
+
+ @if (!loading && rows.length > 0) {
+
+
+
+
+ Showing {{ pageStart }}–{{ pageEnd }} of
+ {{ totalFilteredRecords }}
+
+ @if (selected.size > 0) {
+
+
+ {{ selected.size }} selected
+
+ }
+
+
+
+
+
+
+
+
+
+ Client
+ Contact
+ Address
+ Artefacts
+ Completion
+ Actions
+
+
+
+ @for (row of rows; track row.clientId) {
+
+
+
+
+
+
+
{{ row.fullName?.charAt(0) || '?' }}
+
+
{{ row.fullName }}
+
+ {{ row.accountNo }}
+
+
+
+
+
+
+
+ {{ row.email || 'No email' }}
+
+
+
+ {{ row.mobile || 'No mobile' }}
+
+
+
+
+ {{ row.address || 'No address on file' }}
+
+
+
+
+ P
+
+
+ S
+
+
+ ID
+
+
+ Ad
+
+
+
+
+ = 2 && artefactCompletion(row) < 4"
+ [class.rm-completion--low]="artefactCompletion(row) < 2"
+ >
+ {{ artefactCompletion(row) }}/4
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+ }
+
+
+ @if (selected.size > 0) {
+
+
+
+
+ {{ selected.size }} selected
+
+ @if (selectedIncompleteCount > 0) {
+
+
+ {{ selectedIncompleteCount }} incomplete
+
+ }
+
+
+
+ Clear
+
+
+
+ Create Auto ZIP ({{ selected.size }})
+
+
+
+
+ }
+
+
+ @if (showViewDialog && viewDetails) {
+
+
+
+
+
+
+
+
+ {{ viewDetails.row.fullName?.charAt(0) || '?' }}
+
+
+
{{ viewDetails.row.fullName }}
+
+
+ CIF {{ viewDetails.row.accountNo }}
+
+ •
+
+
+ {{ viewDetails.row.email || 'No email' }}
+
+ •
+
+
+ {{ viewDetails.row.mobile || 'No mobile' }}
+
+
+
+
+
+ @if (viewLoading) {
+
+
+ Loading KYC details…
+
+ }
+
+
+
+ KYC Snapshot
+
+
+
+
KYC Status
+ @if (viewDetails.kycStatus) {
+
+ {{ viewDetails.kycStatus }}
+
+ } @else {
+
—
+ }
+
+
+
+
KYC Date
+
+ {{ viewDetails.kycDate || '—' }}
+
+
+
+
+
Expiry Date
+
+ {{ viewDetails.expiryDate || '—' }}
+
+
+
+
+
Risk Category
+
+ {{ viewDetails.riskCategory || '—' }}
+
+
+
+
+
+
+ CKYC Documents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ label }}
+
+ {{ slot.name }}
+ @if (slot.key) {
+ {{ slot.key }}
+ }
+
+
+
+
+
+ @if (slot.files?.length) {
+
+ @for (file of slot.files; track file.id) {
+
+ @if (file.previewUrl && file.isImage !== false && !file.isPdf) {
+
+ } @else if (file.isPdf) {
+
+
+
+ } @else {
+
+
+
+ }
+
+ @if (file.fileName) {
+ {{ file.fileName }}
+ } @else {
+ {{ file.name }}
+ }
+
+
+
+ }
+
+ } @else if (slot.present) {
+
+
+ Loading preview…
+
+ }
+
+
+
+
+
+
+
+ }
+
+
+ @if (showZipDialog) {
+
+
+
+
+
+
+ Bundling {{ selected.size }} client record{{ selected.size === 1 ? '' : 's' }}
+ into CERSAI-format ZIP. Files will be packaged with an SHA-256 checksum and registered for SFTP upload.
+
+
+ @if (selectedIncompleteCount > 0 && !zipRunning && !zipResult) {
+
+
+
+ {{ selectedIncompleteCount }} record(s) are missing artefacts.
+ They will still be packaged but flagged as incomplete in the manifest.
+
+
+ }
+
+ @if (!zipRunning && !zipResult) {
+
+ }
+
+
+
+ @for (phase of zipPhases; track phase.key; let i = $index) {
+
+
+ @if (phase.status === 'done') {
+
+ } @else if (phase.status === 'active') {
+
+ } @else if (phase.status === 'failed') {
+
+ } @else {
+ {{ i + 1 }}
+ }
+
+
+
+ {{ phase.label }}
+
+ @if (phase.message) {
+
{{ phase.message }}
+ }
+
+
+ }
+
+
+ @if (zipResult && !zipResult.error) {
+
+
+
+ Batch created successfully
+
+
+
+ Batch ID
+ {{ zipResult.batchId }}
+
+
+ Filename
+ {{ zipResult.filename }}
+
+
+ Records
+ {{ zipResult.recordCount }}
+
+ @if (zipResult.sizeBytes) {
+
+ Size
+ {{ (zipResult.sizeBytes / 1024 / 1024).toFixed(2) }} MB
+
+ }
+ @if (zipResult.createdOn) {
+
+ Created on
+ {{ zipResult.createdOn }}
+
+ }
+ @if (zipResult.documentId) {
+
+ Document ID
+ #{{ zipResult.documentId }}
+
+ }
+
+ Checksum
+ {{ zipResult.checksum }}
+
+
+
+
+ @if (zipResult.manifest && zipResult.manifest.length > 0) {
+
+
+
+ View manifest — {{ zipResult.manifest.length }} record(s) packaged
+
+
+ {{ manifestStats.full }} complete
+
+ @if (manifestStats.partial > 0) {
+
+ {{ manifestStats.partial }} partial
+
+ }
+
+
+
+ @if (manifestExpanded) {
+
+
+
+
+ Client
+ P
+ S
+ ID
+ Ad
+ Total size
+
+
+
+ @for (entry of zipResult.manifest; track entry.clientId) {
+
+
+
+ {{ entry.displayName || 'Client ' + entry.clientId }}
+ @if (entry.cifNumber) {
+
+ {{ entry.cifNumber }}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
+
+
+ }
+
+
+
+ }
+
+ }
+ }
+
+ @if (zipResult?.error) {
+
+
+
+ Batch creation failed
+ {{ zipResult!.error }}
+
+
+ }
+
+
+
+
+
+ }
+
+
+ @if (showUploadDialog) {
+
+
+
+
+
+
+ Transferring batch to the CERSAI SFTP drop server and waiting for
+ acknowledgement. On success, the returned CKYC numbers will be saved
+ against each client's master record.
+
+
+ @if (uploadResult?.status === 'ALREADY_UPLOADED') {
+
+
+
+ Batch already uploaded.
+ {{ uploadResult?.remarks || 'No action was taken.' }}
+
+
+ }
+
+
+
+ @for (phase of uploadPhases; track phase.key; let i = $index) {
+
+
+ @if (phase.status === 'done') {
+
+ } @else if (phase.status === 'active') {
+
+ } @else if (phase.status === 'failed') {
+
+ } @else {
+ {{ i + 1 }}
+ }
+
+
+
+ {{ phase.label }}
+
+ @if (phase.message) {
+
{{ phase.message }}
+ }
+
+
+ }
+
+
+ @if (uploadResult && !uploadResult.error) {
+
+
+
+
+ @if (uploadResult.status === 'ALREADY_UPLOADED') {
+ Batch already uploaded to CERSAI
+ } @else {
+ Upload successful — ACK received
+ }
+
+
+
+
+ CERSAI Reference
+ {{ uploadResult.cersaiReference }}
+
+
+ Batch ID
+ {{ uploadResult.batchId }}
+
+
+ Uploaded on
+ {{ uploadResult.uploadedOn }}
+
+ @if (uploadResult.receivedAt) {
+
+ Received at
+ {{ uploadResult.receivedAt }}
+
+ }
+
+ Records
+
+ {{ uploadResult.processedRecords }} / {{ uploadResult.totalRecords }}
+
+
+
+ Status
+
+
+ {{ uploadResult.status }}
+
+
+
+
+ Checksum sent
+ {{ uploadResult.checksumSent }}
+
+
+ Checksum verified
+ {{ uploadResult.checksumVerified }}
+
+
+
+
+ @if (uploadResult.perClientCkycNumbers && uploadResult.perClientCkycNumbers.length > 0) {
+
+
+
+ Issued CKYC numbers ({{ uploadResult.perClientCkycNumbers.length }})
+
+
+
+
+
+ Client
+ CIF
+ CKYC Number
+ Status
+
+
+
+ @for (c of uploadResult.perClientCkycNumbers; track c.clientId) {
+
+ #{{ c.clientId }}
+ {{ c.cifNumber }}
+ {{ c.ckycNumber }}
+
+
+ {{ c.status }}
+
+
+
+ }
+
+
+
+
+ }
+ }
+
+ @if (uploadResult?.error) {
+
+
+
+ Upload failed
+ {{ uploadResult!.error }}
+
+
+ }
+
+
+
+
+
+ }
diff --git a/src/app/ckyc/record-master/record-master.component.scss b/src/app/ckyc/record-master/record-master.component.scss
new file mode 100644
index 000000000..8eb33fc24
--- /dev/null
+++ b/src/app/ckyc/record-master/record-master.component.scss
@@ -0,0 +1,1922 @@
+/**
+ * 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/.
+ */
+
+$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;
+$green: #1d9e75;
+$green-light: #e1f5ee;
+$red: #d85a30;
+$red-light: #faece7;
+$amber: #ba7517;
+$amber-light: #faeeda;
+$teal-900: #085041;
+
+.rm-container { padding: 20px 24px 40px; }
+
+.rm-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+ flex-wrap: wrap;
+
+ &__left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ min-width: 0;
+ }
+
+ &__icon {
+ width: 44px;
+ height: 44px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, $primary, #4285f4);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ box-shadow: 0 3px 10px rgba(26, 115, 232, 0.25);
+ flex-shrink: 0;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ &__sub {
+ margin: 2px 0 0;
+ font-size: 13px;
+ color: $gray-500;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+ }
+}
+
+.rm-btn {
+ border-radius: 8px !important;
+ font-size: 13px !important;
+ font-weight: 600 !important;
+ height: 40px !important;
+ padding: 0 16px !important;
+ display: inline-flex !important;
+ align-items: center;
+ gap: 8px;
+
+ fa-icon { font-size: 13px; }
+
+ &--ghost { color: $gray-700 !important; border-color: $gray-200 !important; }
+
+ &--primary {
+ box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
+ }
+}
+
+/* 3-tab filter — elevated specificity + !important to beat Material button resets */
+.rm-tabs {
+ display: flex !important;
+ gap: 10px !important;
+ margin-bottom: 16px !important;
+ background: $white !important;
+ border: 1px solid $gray-200 !important;
+ border-radius: 14px !important;
+ padding: 8px !important;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) !important;
+}
+
+button.rm-tab,
+.rm-tabs .rm-tab {
+ flex: 1 1 0 !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 10px !important;
+ padding: 12px 20px !important;
+ min-height: 48px !important;
+ height: auto !important;
+ border: 1.5px solid transparent !important;
+ border-radius: 10px !important;
+ background: transparent !important;
+ color: $gray-700 !important;
+ font-size: 13px !important;
+ font-weight: 600 !important;
+ cursor: pointer !important;
+ transition: all 0.15s !important;
+ font-family: inherit !important;
+ white-space: nowrap !important;
+ min-width: 0 !important;
+ line-height: 1 !important;
+ letter-spacing: 0.1px !important;
+ outline: none !important;
+ box-shadow: none !important;
+
+ fa-icon {
+ font-size: 14px !important;
+ color: $gray-500 !important;
+ display: inline-flex !important;
+ align-items: center !important;
+ }
+
+ > span {
+ display: inline-flex !important;
+ align-items: center !important;
+ }
+
+ &:hover {
+ background: $gray-50 !important;
+ color: $gray-900 !important;
+
+ fa-icon { color: $primary !important; }
+ }
+}
+
+.rm-tab__count {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-width: 24px !important;
+ height: 22px !important;
+ padding: 0 9px !important;
+ border-radius: 11px !important;
+ background: $gray-100 !important;
+ color: $gray-700 !important;
+ font-size: 11px !important;
+ font-weight: 700 !important;
+ font-variant-numeric: tabular-nums !important;
+ margin-left: 2px !important;
+}
+
+/* Active state — blue / amber / green variants */
+button.rm-tab.rm-tab--active,
+.rm-tabs .rm-tab.rm-tab--active {
+ background: $primary-light !important;
+ border-color: rgba($primary, 0.35) !important;
+ color: $primary-dark !important;
+ box-shadow: 0 2px 8px rgba($primary, 0.12) !important;
+
+ fa-icon { color: $primary !important; }
+
+ .rm-tab__count {
+ background: $primary !important;
+ color: $white !important;
+ }
+}
+
+button.rm-tab--amber.rm-tab--active,
+.rm-tabs .rm-tab--amber.rm-tab--active {
+ background: $amber-light !important;
+ border-color: rgba($amber, 0.4) !important;
+ color: #633806 !important;
+ box-shadow: 0 2px 8px rgba($amber, 0.18) !important;
+
+ fa-icon { color: $amber !important; }
+
+ .rm-tab__count {
+ background: $amber !important;
+ color: $white !important;
+ }
+}
+
+button.rm-tab--green.rm-tab--active,
+.rm-tabs .rm-tab--green.rm-tab--active {
+ background: $green-light !important;
+ border-color: rgba($green, 0.4) !important;
+ color: $teal-900 !important;
+ box-shadow: 0 2px 8px rgba($green, 0.18) !important;
+
+ fa-icon { color: $green !important; }
+
+ .rm-tab__count {
+ background: $green !important;
+ color: $white !important;
+ }
+}
+
+@media (width <= 680px) {
+ .rm-tabs { flex-direction: column; }
+ .rm-tab { justify-content: space-between; }
+}
+
+/* Filters */
+.rm-filters {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 12px;
+ padding: 18px 20px;
+ margin-bottom: 18px;
+
+ &__grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px 18px;
+ margin-bottom: 16px;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ padding-top: 14px;
+ border-top: 1px dashed $gray-200;
+ }
+}
+
+@media (width <= 1100px) {
+ .rm-filters__grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (width <= 720px) {
+ .rm-filters__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (width <= 480px) {
+ .rm-filters__grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.rm-field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+
+ &--remarks {
+ margin: 4px 0 14px;
+ }
+}
+
+.rm-textarea {
+ resize: vertical;
+ min-height: 50px;
+ height: auto;
+ font-family: inherit;
+ line-height: 1.5;
+}
+
+.rm-label__opt {
+ font-weight: 500;
+ color: $gray-500;
+ font-size: 10px;
+ margin-left: 2px;
+}
+
+.rm-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-700;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: $primary; font-size: 11px; }
+}
+
+.rm-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;
+ height: 40px;
+ box-sizing: border-box;
+ transition: all 0.15s;
+
+ &::placeholder { color: $gray-500; }
+
+ &:focus {
+ outline: none;
+ border-color: $primary;
+ background: $white;
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
+ }
+
+ &--sm {
+ width: auto;
+ padding: 6px 10px 6px 10px;
+ font-size: 12px;
+ height: 30px;
+ }
+}
+
+/* Native
— custom chevron, no overlap */
+select.rm-input {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ padding-right: 34px;
+ background-image: url("data:image/svg+xml;utf8, ");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ background-size: 10px 6px;
+ cursor: pointer;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+/* Native date input — cleaner calendar icon */
+input[type='date'].rm-input {
+ padding-right: 10px;
+
+ &::-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; }
+
+ &:not(:focus):placeholder-shown::-webkit-datetime-edit {
+ color: $gray-500;
+ }
+}
+
+/* State banners */
+.rm-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 10px;
+ padding: 36px 20px;
+ border-radius: 12px;
+ font-size: 13px;
+
+ &--loading {
+ color: $primary;
+ background: $primary-light;
+ border: 1px dashed rgba($primary, 0.3);
+
+ fa-icon { font-size: 20px; }
+ }
+
+ &--error {
+ color: $red;
+ background: $red-light;
+ border: 1px solid rgba($red, 0.3);
+
+ fa-icon { font-size: 18px; }
+ }
+
+ &--empty {
+ color: $gray-500;
+ background: $gray-50;
+ border: 1px dashed $gray-200;
+
+ fa-icon { color: $green; }
+
+ h3 {
+ margin: 4px 0 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ p { margin: 0; }
+ }
+}
+
+/* Results */
+.rm-results {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding-bottom: 100px; /* room for floating action bar */
+
+ &__head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 2px 4px;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ &__count {
+ font-size: 12px;
+ color: $gray-700;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: $primary; }
+
+ strong { color: $gray-900; font-weight: 700; }
+ }
+
+ &__selected {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: $teal-900;
+ background: $green-light;
+ padding: 5px 12px;
+ border-radius: 14px;
+ border: 1px solid rgba($green, 0.3);
+
+ strong { font-weight: 700; }
+ }
+}
+
+/* ---------- Table ---------- */
+.rm-table-wrap {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 12px;
+ overflow: hidden;
+ overflow-x: auto;
+}
+
+.rm-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+
+ thead tr {
+ background: $gray-50;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ th {
+ text-align: left;
+ padding: 12px 14px;
+ font-size: 10px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 12px 14px;
+ border-bottom: 1px solid $gray-100;
+ color: $gray-900;
+ vertical-align: middle;
+ }
+
+ tbody tr:last-child td { border-bottom: none; }
+
+ &__check {
+ width: 42px;
+ text-align: center;
+
+ input[type='checkbox'] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ accent-color: $primary;
+ }
+ }
+
+ &__artefacts { width: 180px; }
+ &__completion { width: 90px; text-align: center; }
+ &__actions { width: 90px; text-align: right; }
+}
+
+.rm-row {
+ cursor: pointer;
+ transition: background 0.12s;
+
+ &:hover { background: #f5f8fc; }
+
+ &--selected {
+ background: $primary-light !important;
+
+ td { border-bottom-color: rgba($primary, 0.15); }
+ }
+
+ &__client {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 200px;
+ }
+
+ &__avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, $primary, #4285f4);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 700;
+ flex-shrink: 0;
+ }
+
+ &__ident { min-width: 0; }
+
+ &__name {
+ font-size: 13px;
+ font-weight: 600;
+ color: $gray-900;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 200px;
+ }
+
+ &__account {
+ font-size: 11px;
+ color: $gray-500;
+ font-family: 'SF Mono', Consolas, monospace;
+ margin-top: 1px;
+
+ fa-icon { font-size: 9px; }
+ }
+
+ &__line {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: $gray-700;
+
+ + .rm-row__line { margin-top: 3px; }
+
+ fa-icon { color: $primary; font-size: 10px; }
+
+ span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 200px;
+ }
+ }
+
+ &__missing {
+ color: $gray-500;
+ font-style: italic;
+ }
+
+ &__address {
+ font-size: 12px;
+ color: $gray-700;
+ max-width: 280px;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ line-height: 1.45;
+
+ fa-icon {
+ color: $primary;
+ font-size: 10px;
+ margin-top: 3px;
+ flex-shrink: 0;
+ }
+
+ span {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ word-break: break-word;
+ white-space: normal;
+ }
+ }
+
+ &__chips {
+ display: flex;
+ gap: 4px;
+ }
+}
+
+.rm-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 3px 7px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 700;
+ border: 1px solid transparent;
+
+ fa-icon { font-size: 8px; }
+
+ &--ok { background: $green-light; color: $teal-900; border-color: rgba($green, 0.2); }
+ &--miss { background: $red-light; color: $red; border-color: rgba($red, 0.2); }
+}
+
+.rm-action-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ border: 1px solid $gray-200;
+ background: $white;
+ color: $gray-700;
+ cursor: pointer;
+ margin-left: 4px;
+ transition: all 0.12s;
+
+ fa-icon { font-size: 11px; }
+
+ &:hover {
+ background: $primary-light;
+ color: $primary;
+ border-color: $primary;
+ }
+
+ &--primary {
+ background: $primary;
+ color: $white;
+ border-color: $primary;
+
+ &:hover {
+ background: #1557b5;
+ color: $white;
+ }
+ }
+}
+
+/* ---------- Floating action bar ---------- */
+.rm-action-bar {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 12px 18px 12px 20px;
+ background: $gray-900;
+ color: $white;
+ border-radius: 14px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
+ max-width: calc(100vw - 40px);
+ animation: rm-slide-up 0.25s ease-out;
+
+ &__info {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ }
+
+ &__count {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+
+ fa-icon { color: #9fe1cb; }
+ strong { color: $white; font-weight: 700; font-size: 15px; }
+ }
+
+ &__warn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 10px;
+ background: rgba($amber, 0.2);
+ color: #facd70;
+ font-size: 11px;
+ font-weight: 600;
+
+ fa-icon { font-size: 10px; }
+ }
+
+ &__btns {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+}
+
+@keyframes rm-slide-up {
+ from { transform: translate(-50%, 20px); opacity: 0; }
+ to { transform: translate(-50%, 0); opacity: 1; }
+}
+
+.rm-btn--ghost-light {
+ background: rgba(255, 255, 255, 0.1) !important;
+ color: $white !important;
+ border-color: rgba(255, 255, 255, 0.2) !important;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.2) !important;
+ }
+}
+
+.rm-btn--amber {
+ background: $amber !important;
+ color: $white !important;
+ border: none !important;
+
+ &:hover { background: #a0651a !important; }
+}
+
+/* ---------- Modal ---------- */
+.rm-modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 25, 45, 0.55);
+ backdrop-filter: blur(3px);
+ z-index: 1200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ animation: rm-fade 0.2s ease-out;
+
+ &__card {
+ background: $white;
+ border-radius: 16px;
+ width: 100%;
+ max-width: 560px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
+ animation: rm-zoom 0.25s ease-out;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 22px;
+ border-bottom: 1px solid $gray-100;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ font-weight: 700;
+ color: $gray-900;
+
+ fa-icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, $amber, #d4892f);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ box-shadow: 0 3px 10px rgba($amber, 0.3);
+ }
+ }
+
+ &__close {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid $gray-200;
+ background: $white;
+ color: $gray-500;
+ cursor: pointer;
+ transition: all 0.12s;
+
+ &:not(:disabled):hover {
+ color: $red;
+ border-color: $red;
+ background: $red-light;
+ }
+
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
+ }
+
+ &__body {
+ padding: 20px 22px;
+ overflow-y: auto;
+ flex: 1;
+ }
+
+ &__intro {
+ font-size: 13px;
+ color: $gray-700;
+ line-height: 1.55;
+ margin: 0 0 16px;
+
+ strong { color: $gray-900; font-weight: 700; }
+ }
+
+ &__warn {
+ display: flex;
+ gap: 10px;
+ padding: 12px 14px;
+ background: $amber-light;
+ border: 1px solid rgba($amber, 0.3);
+ border-left: 3px solid $amber;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ font-size: 12px;
+ color: #633806;
+ line-height: 1.5;
+
+ fa-icon { color: $amber; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
+
+ strong { display: block; font-weight: 700; color: #4d2a05; }
+ }
+
+ &__result {
+ margin-top: 16px;
+ padding: 16px;
+ background: $green-light;
+ border: 1px solid rgba($green, 0.25);
+ border-radius: 10px;
+ }
+
+ &__result-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 700;
+ color: $teal-900;
+ margin-bottom: 12px;
+
+ fa-icon { color: $green; font-size: 16px; }
+ }
+
+ &__result-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px 16px;
+ }
+
+ &__result-label {
+ display: block;
+ font-size: 10px;
+ text-transform: uppercase;
+ font-weight: 700;
+ color: $teal-900;
+ opacity: 0.7;
+ letter-spacing: 0.3px;
+ margin-bottom: 2px;
+ }
+
+ &__result-value {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ color: $teal-900;
+ word-break: break-all;
+
+ &--mono {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ }
+ }
+
+ &__result-grid-span {
+ grid-column: 1 / -1;
+ }
+
+ &__error {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ background: $red-light;
+ border: 1px solid rgba($red, 0.25);
+ border-left: 3px solid $red;
+ border-radius: 10px;
+ margin-top: 12px;
+ font-size: 12px;
+ color: #5a1a0e;
+
+ fa-icon { color: $red; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
+
+ strong {
+ display: block;
+ font-weight: 700;
+ color: $red;
+ margin-bottom: 2px;
+ }
+ }
+}
+
+/* ---------- Manifest collapsible ---------- */
+.rm-manifest {
+ margin-top: 14px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+ overflow: hidden;
+
+ &__toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ background: $gray-50;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ color: $gray-700;
+ text-align: left;
+ transition: background 0.15s;
+
+ &:hover { background: $gray-100; }
+
+ > fa-icon:first-child {
+ color: $primary;
+ font-size: 12px;
+ flex-shrink: 0;
+ }
+
+ > span:nth-child(2) {
+ flex: 1;
+ min-width: 0;
+
+ strong { color: $gray-900; font-weight: 700; margin-right: 4px; }
+ }
+ }
+
+ &__stats {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+ }
+
+ &__stat {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 9px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+
+ fa-icon { font-size: 9px; }
+
+ &--full {
+ background: $green-light;
+ color: $teal-900;
+ border: 1px solid rgba($green, 0.25);
+ }
+
+ &--partial {
+ background: $amber-light;
+ color: $amber;
+ border: 1px solid rgba($amber, 0.3);
+ }
+ }
+
+ &__body {
+ max-height: 320px;
+ overflow-y: auto;
+ border-top: 1px solid $gray-200;
+ }
+
+ &__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+
+ thead tr {
+ background: $gray-50;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 9px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ white-space: nowrap;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ td {
+ padding: 8px 12px;
+ border-bottom: 1px solid $gray-100;
+ vertical-align: middle;
+ }
+
+ tbody tr:last-child td { border-bottom: none; }
+ tbody tr:hover { background: #f5f8fc; }
+ }
+
+ &__th-center { text-align: center !important; width: 38px; }
+ &__th-right { text-align: right !important; }
+ &__td-center { text-align: center; }
+ &__td-right { text-align: right; }
+
+ &__client {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ strong {
+ font-size: 12px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+ }
+
+ &__cif {
+ font-size: 10px;
+ color: $gray-500;
+ font-family: 'SF Mono', Consolas, monospace;
+
+ fa-icon { font-size: 8px; margin-right: 2px; }
+ }
+
+ &__chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ font-size: 9px;
+ font-weight: 700;
+
+ &--ok {
+ background: $green-light;
+ color: $green;
+ border: 1px solid rgba($green, 0.3);
+ }
+
+ &--miss {
+ background: $red-light;
+ color: $red;
+ border: 1px solid rgba($red, 0.3);
+ }
+ }
+
+ &__size {
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-700;
+ font-family: 'SF Mono', Consolas, monospace;
+ white-space: nowrap;
+ }
+}
+
+.rm-modal__footer {
+ padding: 16px 22px;
+ border-top: 1px solid $gray-100;
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ background: $gray-50;
+}
+
+/* Stepper */
+.rm-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.rm-step {
+ display: flex;
+ gap: 14px;
+ padding: 10px 0;
+ position: relative;
+
+ &:not(:last-child)::after {
+ content: '';
+ position: absolute;
+ left: 15px;
+ top: 38px;
+ bottom: -4px;
+ width: 2px;
+ background: $gray-200;
+ }
+
+ &__indicator {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: $white;
+ border: 2px solid $gray-200;
+ color: $gray-500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+ transition: all 0.25s;
+ }
+
+ &__content {
+ padding-top: 6px;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__label {
+ font-size: 13px;
+ font-weight: 600;
+ color: $gray-700;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ fa-icon { font-size: 12px; color: $gray-500; }
+ }
+
+ &__msg {
+ font-size: 11px;
+ color: $gray-500;
+ margin-top: 2px;
+ }
+
+ &--active {
+ .rm-step__indicator {
+ background: $primary;
+ border-color: $primary;
+ color: $white;
+ box-shadow: 0 0 0 4px rgba($primary, 0.15);
+ }
+
+ .rm-step__label {
+ color: $primary;
+
+ fa-icon { color: $primary; }
+ }
+ }
+
+ &--done {
+ .rm-step__indicator {
+ background: $green;
+ border-color: $green;
+ color: $white;
+ }
+
+ &::after { background: $green; }
+
+ .rm-step__label {
+ color: $teal-900;
+
+ fa-icon { color: $green; }
+ }
+ }
+
+ &--failed {
+ .rm-step__indicator {
+ background: $red;
+ border-color: $red;
+ color: $white;
+ }
+
+ .rm-step__label { color: $red; }
+ }
+}
+
+@keyframes rm-fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes rm-zoom {
+ from { transform: scale(0.95); opacity: 0; }
+ to { transform: scale(1); opacity: 1; }
+}
+
+/* ---------- View details modal ---------- */
+.rm-modal__card--wide {
+ max-width: 720px;
+}
+
+.rm-modal__title--blue fa-icon {
+ background: linear-gradient(135deg, $primary, #4285f4) !important;
+ box-shadow: 0 3px 10px rgba($primary, 0.3) !important;
+}
+
+.rm-modal__title--teal fa-icon {
+ background: linear-gradient(135deg, #0f6e56, #1d9e75) !important;
+ box-shadow: 0 3px 10px rgba(29, 158, 117, 0.3) !important;
+}
+
+.rm-modal__warn--blue {
+ background: $primary-light !important;
+ border-color: rgba($primary, 0.3) !important;
+ border-left-color: $primary !important;
+ color: #0c447c !important;
+
+ fa-icon { color: $primary !important; }
+ strong { color: #0c447c !important; }
+}
+
+/* CKYC numbers table */
+.rm-ckyc-numbers {
+ margin-top: 16px;
+ background: $white;
+ border: 1px solid rgba($green, 0.25);
+ border-radius: 10px;
+ overflow: hidden;
+
+ &__title {
+ padding: 10px 14px;
+ background: $green-light;
+ font-size: 11px;
+ font-weight: 700;
+ color: $teal-900;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ border-bottom: 1px solid rgba($green, 0.2);
+
+ fa-icon { color: $green; font-size: 12px; }
+ }
+
+ &__table-wrap {
+ max-height: 240px;
+ overflow-y: auto;
+ }
+
+ &__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ thead tr {
+ background: $gray-50;
+ position: sticky;
+ top: 0;
+ }
+
+ th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 9px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ border-bottom: 1px solid $gray-200;
+ }
+
+ td {
+ padding: 9px 12px;
+ border-bottom: 1px solid $gray-100;
+ color: $gray-900;
+ }
+
+ tbody tr:last-child td { border-bottom: none; }
+ }
+
+ &__mono {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ color: $gray-700;
+ }
+
+ &__ckyc {
+ color: $teal-900 !important;
+ font-weight: 700;
+ background: rgba($green, 0.06);
+ }
+
+ &__status { text-align: right; white-space: nowrap; }
+}
+
+.rm-view {
+ &__client {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 16px;
+ background: linear-gradient(135deg, $primary-light 0%, #f0fbf6 100%);
+ border: 1px solid rgba($primary, 0.15);
+ border-radius: 12px;
+ margin-bottom: 18px;
+ }
+
+ &__avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, $primary, #4285f4);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: 700;
+ flex-shrink: 0;
+ box-shadow: 0 3px 10px rgba($primary, 0.25);
+ }
+
+ &__client-info { min-width: 0; flex: 1; }
+
+ &__client-name {
+ font-size: 16px;
+ font-weight: 700;
+ color: $gray-900;
+ margin-bottom: 3px;
+ }
+
+ &__client-meta {
+ font-size: 11px;
+ color: $gray-700;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+
+ fa-icon { color: $primary; font-size: 10px; margin-right: 3px; }
+ }
+
+ &__cif {
+ strong {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-weight: 700;
+ color: $primary;
+ }
+ }
+
+ &__sep { color: $gray-200; margin: 0 2px; }
+
+ &__loading {
+ padding: 10px 14px;
+ font-size: 12px;
+ color: $primary;
+ background: $primary-light;
+ border-radius: 8px;
+ margin-bottom: 14px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ fa-icon { font-size: 14px; }
+ }
+
+ &__section-title {
+ font-size: 11px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin: 20px 0 10px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: $primary; font-size: 12px; }
+
+ &:first-of-type { margin-top: 6px; }
+ }
+
+ &__kpis {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ }
+
+ &__docs {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ }
+}
+
+.rm-kpi {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+ padding: 10px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+
+ &__label {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: $gray-500;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+
+ fa-icon { color: $primary; font-size: 10px; }
+ }
+
+ &__value {
+ font-size: 13px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ &__missing { color: $gray-500; font-style: italic; font-weight: 500; }
+
+ &__pill {
+ display: inline-flex;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ width: max-content;
+
+ &--ok {
+ background: $green-light;
+ color: $teal-900;
+ border: 1px solid rgba($green, 0.25);
+ }
+
+ &--warn {
+ background: $amber-light;
+ color: $amber;
+ border: 1px solid rgba($amber, 0.3);
+ }
+
+ &--err {
+ background: $red-light;
+ color: $red;
+ border: 1px solid rgba($red, 0.3);
+ }
+ }
+}
+
+.rm-doc {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+ transition: border-color 0.15s;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: $primary-light;
+ color: $primary;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+ }
+
+ &__body { flex: 1; min-width: 0; }
+
+ &__label {
+ font-size: 10px;
+ font-weight: 700;
+ color: $gray-500;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ margin-bottom: 2px;
+ }
+
+ &__value {
+ font-size: 13px;
+ font-weight: 600;
+ color: $gray-900;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ &__key {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 10px;
+ background: $primary-light;
+ color: $primary;
+ padding: 2px 7px;
+ border-radius: 8px;
+ font-weight: 600;
+ }
+
+ &__state {
+ font-size: 18px;
+ flex-shrink: 0;
+ }
+
+ &__files {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: 8px;
+ border-top: 1px dashed $gray-200;
+ }
+
+ &__placeholder {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ background: $gray-50;
+ border-radius: 8px;
+ font-size: 11px;
+ color: $gray-500;
+ font-style: italic;
+
+ fa-icon { color: $primary; font-size: 11px; }
+ }
+
+ &--ok {
+ border-color: rgba($green, 0.25);
+ background: #f0fbf6;
+
+ .rm-doc__icon {
+ background: $green-light;
+ color: $teal-900;
+ }
+
+ .rm-doc__state { color: $green; }
+ .rm-doc__files { border-top-color: rgba($green, 0.2); }
+ }
+
+ &--miss {
+ border-color: rgba($red, 0.2);
+ background: #fef6f3;
+
+ .rm-doc__icon {
+ background: $red-light;
+ color: $red;
+ }
+
+ .rm-doc__state { color: $red; }
+ }
+}
+
+/* File preview row inside a doc tile */
+.rm-file {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 8px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ border-color: $primary;
+ box-shadow: 0 2px 6px rgba($primary, 0.1);
+
+ .rm-file__open { opacity: 1; color: $primary; }
+ }
+
+ &__thumb {
+ width: 44px;
+ height: 44px;
+ border-radius: 6px;
+ object-fit: cover;
+ background: $gray-100;
+ border: 1px solid $gray-200;
+ flex-shrink: 0;
+
+ &--icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $primary;
+ font-size: 18px;
+ }
+
+ &--pdf {
+ background: #fdecec;
+ color: #c62828;
+ }
+ }
+
+ &__info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ &__name {
+ font-size: 11px;
+ font-weight: 600;
+ color: $gray-900;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__open {
+ font-size: 10px;
+ color: $gray-500;
+ opacity: 0.5;
+ transition: all 0.15s;
+ flex-shrink: 0;
+ }
+}
+
+@media (width <= 640px) {
+ .rm-view__kpis,
+ .rm-view__docs {
+ grid-template-columns: 1fr;
+ }
+}
+
+.rm-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 14px;
+}
+
+.rm-card {
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 12px;
+ padding: 16px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ cursor: pointer;
+ transition: all 0.18s;
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 3px;
+ background: $primary;
+ opacity: 0.6;
+ }
+
+ &:hover {
+ border-color: $primary-light;
+ box-shadow: 0 4px 14px rgba(26, 115, 232, 0.1);
+ transform: translateY(-1px);
+ }
+
+ &:focus-visible {
+ outline: 2px solid $primary;
+ outline-offset: 2px;
+ }
+
+ &__top {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, $primary, #4285f4);
+ color: $white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 700;
+ flex-shrink: 0;
+ }
+
+ &__id {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__name {
+ font-size: 14px;
+ font-weight: 600;
+ color: $gray-900;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__account {
+ font-size: 11px;
+ color: $gray-500;
+ font-family: 'SF Mono', Consolas, monospace;
+ margin-top: 2px;
+
+ fa-icon { font-size: 10px; margin-right: 2px; }
+ }
+
+ &__completion {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+ }
+
+ &__completion-label {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: $gray-500;
+ letter-spacing: 0.04em;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 10px 12px;
+ background: $gray-50;
+ border-radius: 8px;
+ }
+
+ &__artefacts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 8px;
+ padding-top: 4px;
+ border-top: 1px dashed $gray-200;
+
+ .rm-btn {
+ flex: 1;
+ justify-content: center;
+ height: 36px !important;
+ font-size: 12px !important;
+ }
+ }
+}
+
+.rm-completion {
+ padding: 4px 10px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 700;
+
+ &--full {
+ background: $green-light;
+ color: $teal-900;
+ }
+
+ &--half {
+ background: $amber-light;
+ color: $amber;
+ }
+
+ &--low {
+ background: $red-light;
+ color: $red;
+ }
+}
+
+.rm-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: $gray-700;
+ min-width: 0;
+
+ fa-icon { color: $primary; font-size: 11px; flex-shrink: 0; }
+
+ span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ }
+
+ &--missing { color: $gray-500; font-style: italic; }
+}
+
+.rm-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 9px;
+ border-radius: 14px;
+ font-size: 11px;
+ font-weight: 600;
+ border: 1px solid transparent;
+
+ fa-icon { font-size: 10px; }
+
+ &--ok {
+ background: $green-light;
+ color: $teal-900;
+ border-color: rgba($green, 0.2);
+ }
+
+ &--miss {
+ background: $red-light;
+ color: $red;
+ border-color: rgba($red, 0.2);
+ }
+}
+
+/* Pagination */
+.rm-pager {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 16px;
+ background: $white;
+ border: 1px solid $gray-200;
+ border-radius: 10px;
+
+ &__size {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: $gray-700;
+ }
+
+ &__nav {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &__label {
+ font-size: 12px;
+ color: $gray-700;
+ padding: 0 8px;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__btn {
+ width: 32px !important;
+ height: 32px !important;
+ min-width: 32px !important;
+ border-radius: 8px !important;
+ color: $gray-700 !important;
+
+ &:disabled { color: $gray-200 !important; }
+ }
+}
+
+@media (width <= 640px) {
+ .rm-container { padding: 14px; }
+ .rm-header { flex-direction: column; align-items: flex-start; }
+ .rm-cards { grid-template-columns: 1fr; }
+ .rm-pager { flex-direction: column; gap: 12px; }
+}
diff --git a/src/app/ckyc/record-master/record-master.component.ts b/src/app/ckyc/record-master/record-master.component.ts
index cb0e10aa8..18425cb0d 100644
--- a/src/app/ckyc/record-master/record-master.component.ts
+++ b/src/app/ckyc/record-master/record-master.component.ts
@@ -6,16 +6,891 @@
* 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 { 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({
selector: 'mifosx-record-master',
templateUrl: './record-master.component.html',
+ styleUrls: ['./record-master.component.scss'],
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();
+
+ /** Cached proof-type id → kind ('id' | 'address') map */
+ private proofKindMap = new Map();
+ 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 = {
+ '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;
+ }
+}
diff --git a/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss b/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss
index ae630b653..645a8b51f 100644
--- a/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss
+++ b/src/app/clients/client-stepper/client-address-step/client-address-step.component.scss
@@ -41,11 +41,11 @@
align-items: center;
gap: 10px;
padding: 24px 16px;
- color: rgba(0, 0, 0, 0.45);
+ color: rgb(0 0 0 / 45%);
font-size: 14px;
- background: rgba(0, 0, 0, 0.02);
+ background: rgb(0 0 0 / 2%);
border-radius: 6px;
- border: 1px dashed rgba(0, 0, 0, 0.12);
+ border: 1px dashed rgb(0 0 0 / 12%);
.empty-icon {
font-size: 18px;
@@ -58,7 +58,7 @@
.panel-icon {
margin-right: 8px;
font-size: 13px;
- color: rgba(0, 0, 0, 0.45);
+ color: rgb(0 0 0 / 45%);
}
}
@@ -85,7 +85,7 @@
.detail-label {
font-size: 12px;
- color: rgba(0, 0, 0, 0.5);
+ color: rgb(0 0 0 / 50%);
font-weight: 500;
}
@@ -94,7 +94,7 @@
color: #333;
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.section-header {
flex-direction: column;
align-items: flex-start;
diff --git a/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss
index 083f40308..174abaa2c 100644
--- a/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss
+++ b/src/app/clients/client-stepper/client-entity-kyc-step/client-entity-kyc-step.component.scss
@@ -12,7 +12,7 @@
&__left { display: flex; align-items: center; gap: 12px; }
&__icon { font-size: 18px; color: #60a5fa; }
&__title { font-size: 16px; font-weight: 600; color: #fff; margin: 0; }
- &__sub { font-size: 11px; color: rgba(255,255,255,0.5); margin: 2px 0 0; }
+ &__sub { font-size: 11px; color: rgb(255 255 255 / 50%); margin: 2px 0 0; }
}
.ec-chips-label {
@@ -133,6 +133,7 @@
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 8px;
+
mat-checkbox {
padding: 8px 12px;
border: 1px solid #e2e8f0;
@@ -197,8 +198,9 @@
margin-top: 12px;
}
-@media (max-width: 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
-@media (max-width: 600px) {
+@media (width <= 900px) { .ec-grid-3 { grid-template-columns: repeat(2, 1fr); } }
+
+@media (width <= 600px) {
.ec-grid-3, .ec-grid-2 { grid-template-columns: 1fr; }
.ec-check-grid { grid-template-columns: 1fr; }
.ec-card-head { padding: 12px 14px; }
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
index 1cd1dc0fe..2494bef6a 100644
--- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
+++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
@@ -55,7 +55,7 @@
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 220px;
overflow-y: auto;
}
@@ -113,6 +113,7 @@
&__left { flex-shrink: 0; }
&__icon { color: #1074b9; }
+
&__photo {
width: 40px;
height: 40px;
@@ -123,6 +124,7 @@
&__info { flex: 1; display: flex; flex-direction: column; }
&__name { font-size: 14px; font-weight: 600; color: #1e293b; }
&__meta { font-size: 12px; color: #64748b; font-family: 'Roboto Mono', monospace; }
+
&__sig {
display: flex;
flex-direction: column;
@@ -139,6 +141,7 @@
background: #fff;
padding: 2px 4px;
}
+
span {
font-size: 9px;
color: #94a3b8;
@@ -225,6 +228,7 @@
background: #dcfce7;
color: #16a34a;
}
+
&.not-verified {
background: #fef3c7;
color: #d97706;
@@ -266,7 +270,7 @@
&:hover {
transform: scale(1.08);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
+ box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
}
}
@@ -309,6 +313,7 @@
width: 100%;
&__icon { color: #1074b9; font-size: 20px; }
+
&__name {
flex: 1;
font-size: 13px;
@@ -318,6 +323,7 @@
overflow: hidden;
text-overflow: ellipsis;
}
+
&__remove {
color: #94a3b8 !important;
width: 28px !important;
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss
index fbea465ae..cac5743b4 100644
--- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss
+++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.scss
@@ -1,7 +1,7 @@
.zoomable {
cursor: zoom-in;
transition: transform 0.15s ease, box-shadow 0.15s ease;
- &:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
+ &:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgb(0 0 0 / 18%); }
}
::ng-deep .image-zoom-panel {
@@ -42,7 +42,7 @@
&__sub {
font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
+ color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
@@ -252,7 +252,7 @@
&--delete:hover mat-icon { color: #ef4444; }
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.fm-step__header {
flex-direction: column;
align-items: flex-start;
@@ -358,6 +358,7 @@
.nom-card__info { flex: 1; }
.nom-card__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-card__meta { font-size: 12px; color: #64748b; display: block; margin-top: 2px; }
+
.nom-card__sig {
flex-shrink: 0;
img { height: 24px; max-width: 60px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px; }
@@ -445,6 +446,7 @@
/* Search */
.nom-search-wrapper { position: relative; }
.nom-search-field { width: 100%; }
+
.nom-search-prefix {
display: inline-flex;
align-items: center;
@@ -460,7 +462,7 @@
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
max-height: 240px;
overflow-y: auto;
margin-top: -12px;
@@ -517,6 +519,7 @@
.nom-selected__info { flex: 1; }
.nom-selected__name { font-weight: 600; font-size: 14px; color: #1e293b; display: block; }
.nom-selected__meta { font-size: 12px; color: #64748b; display: block; }
+
.nom-selected__sig {
display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0;
img { height: 28px; max-width: 70px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; background: #fff; padding: 2px 4px; }
@@ -599,7 +602,7 @@
button { display: flex; align-items: center; gap: 6px; }
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.nom-form-fields { grid-template-columns: 1fr; }
.nom-guardian-card { flex-wrap: wrap; }
.nom-guardian-rel { width: 100%; }
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index aeb05edca..6bbc7ba49 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -619,6 +619,7 @@
Name
Client ID
PAN
+ DIN
Status
Actions
@@ -645,6 +646,17 @@
{{ sig.panNumber || '-' }}
@if (sig.panVerified) { check_circle }
+
+ @if (sig.dinNumber) {
+ {{ sig.dinNumber }}
+ @if (sig.dinVerified) { check_circle }
+ @if (!sig.dinVerified) { cancel }
+ } @else {
+
+ badge Verify DIN
+
+ }
+
{{ sig.status?.value || '-' }}
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
index d00f4ee37..c30cd211e 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
@@ -13,7 +13,7 @@ form {
.section-block {
margin-bottom: 24px;
padding-bottom: 16px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ border-bottom: 1px solid rgb(0 0 0 / 6%);
&:last-child {
border-bottom: none;
@@ -28,7 +28,7 @@ form {
font-size: 15px;
font-weight: 600;
color: #333;
- margin: 0 0 16px 0;
+ margin: 0 0 16px;
.section-icon {
color: var(--md-sys-color-primary, #1074b9);
@@ -235,7 +235,7 @@ form {
border-radius: 12px;
overflow: hidden;
background: #fff;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+ box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
}
.guardian-section__header {
@@ -268,7 +268,7 @@ form {
.guardian-section__sub {
font-size: 11px;
- color: rgba(255, 255, 255, 0.55);
+ color: rgb(255 255 255 / 55%);
}
.guardian-section__age-chips {
@@ -280,9 +280,9 @@ form {
.guardian-age-chip {
font-size: 11px;
font-weight: 500;
- color: rgba(255, 255, 255, 0.8);
+ color: rgb(255 255 255 / 80%);
padding: 3px 10px;
- background: rgba(255, 255, 255, 0.1);
+ background: rgb(255 255 255 / 10%);
border-radius: 10px;
strong {
@@ -638,12 +638,13 @@ form {
padding: 0 !important;
border-radius: 12px !important;
}
+
.mdc-dialog__surface {
border-radius: 12px !important;
}
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.guardian-section__header {
flex-direction: column;
align-items: flex-start;
@@ -738,7 +739,7 @@ form {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
max-height: 280px;
overflow-y: auto;
}
@@ -834,7 +835,7 @@ form {
border-radius: 12px;
overflow: hidden;
background: #fff;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+ box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
}
.guardian-card__header {
@@ -945,7 +946,7 @@ form {
height: 16px;
border-radius: 3px;
overflow: hidden;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 10%);
flex-shrink: 0;
}
@@ -1030,7 +1031,7 @@ form {
width: 24px;
height: 24px;
border: none;
- background: rgba(255, 255, 255, 0.15);
+ background: rgb(255 255 255 / 15%);
border-radius: 4px;
color: #fff;
cursor: pointer;
@@ -1040,7 +1041,7 @@ form {
font-size: 12px;
&:hover {
- background: rgba(255, 255, 255, 0.25);
+ background: rgb(255 255 255 / 25%);
}
}
@@ -1201,6 +1202,7 @@ form {
.mdc-notched-outline__trailing {
border-color: #10b981 !important;
}
+
input:disabled {
color: #059669 !important;
-webkit-text-fill-color: #059669 !important;
@@ -1213,6 +1215,7 @@ form {
.mdc-notched-outline__trailing {
border-color: #0ea5e9 !important;
}
+
input:disabled {
color: #0284c7 !important;
-webkit-text-fill-color: #0284c7 !important;
@@ -1225,6 +1228,7 @@ form {
.mdc-notched-outline__trailing {
border-color: #f59e0b !important;
}
+
input:disabled {
color: #d97706 !important;
-webkit-text-fill-color: #d97706 !important;
@@ -1390,7 +1394,7 @@ form {
}
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.form-grid {
grid-template-columns: 1fr;
}
@@ -1505,6 +1509,7 @@ form {
&--name { flex: 1; font-size: 13px; display: flex; align-items: center; gap: 6px; }
&--id { flex: 0 0 100px; font-size: 12px; color: #64748b; }
&--pan { flex: 0 0 120px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
+ &--din { flex: 0 0 140px; font-size: 12px; color: #1e293b; display: flex; align-items: center; gap: 3px; }
&--status { flex: 0 0 80px; }
&--actions { flex: 0 0 80px; display: flex; gap: 2px; }
}
@@ -1534,6 +1539,32 @@ form {
color: #22c55e;
}
+.verify-cross-sm {
+ font-size: 14px !important;
+ width: 14px !important;
+ height: 14px !important;
+ color: #f59e0b;
+}
+
+.din-verify-btn {
+ font-size: 11px !important;
+ padding: 2px 8px !important;
+ min-height: 24px !important;
+ line-height: 20px !important;
+ border-radius: 6px !important;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #1074b9 !important;
+ border-color: #93c5fd !important;
+
+ .din-verify-icon {
+ font-size: 14px !important;
+ width: 14px !important;
+ height: 14px !important;
+ }
+}
+
.sig-status-chip {
font-size: 11px;
font-weight: 600;
@@ -1561,11 +1592,13 @@ form {
overflow: visible !important;
max-height: none !important;
}
+
.mdc-dialog__surface {
border-radius: 12px !important;
overflow: visible !important;
max-height: none !important;
}
+
.mat-mdc-dialog-content {
max-height: none !important;
overflow: visible !important;
@@ -1625,7 +1658,7 @@ form {
&__sub {
font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
+ color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index ab92a3241..77d89d2ae 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -40,6 +40,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
+import { DinVerifyDialogComponent } from './din-verify-dialog.component';
/**
* Create Client Component
@@ -824,6 +825,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.syncSignatoryNames();
this.fetchClientFullDetails(client.id, sig);
+ this.openDinVerifyDialog(sig);
+ }
+
+ /** Open DIN verification dialog for a signatory */
+ openDinVerifyDialog(sig: any): void {
+ const cid = sig.id?.toString();
+ if (!cid) return;
+
+ const entityName = this.createClientForm.get('fullname')?.value || '';
+
+ const openDialog = (kycId: string, panFromKyc?: string) => {
+ const ref = this.dialog.open(DinVerifyDialogComponent, {
+ width: '620px',
+ maxWidth: '95vw',
+ disableClose: false,
+ autoFocus: true,
+ data: {
+ clientId: cid,
+ kycId,
+ signatoryName: sig.displayName || '',
+ cifNumber: sig.cifNumber || sig.accountNumber || cid,
+ panNumber: sig.panNumber || panFromKyc || '-',
+ entityName
+ }
+ });
+ ref.afterClosed().subscribe((result: any) => {
+ if (result && !result.skipped) {
+ sig.dinNumber = result.dinNumber || '';
+ sig.dinVerified = result.dinVerified || false;
+ sig.directorName = result.directorName || '';
+ sig.dinStatus = result.dinStatus || '';
+ sig.dinDesignation = result.dinDesignation || '';
+ sig.companyMatch = result.companyMatch ?? null;
+ sig.matchedCompany = result.matchedCompany || '';
+ }
+ });
+ };
+
+ // Fetch KYC ID first, then open dialog
+ this.clientService.getClientKyc(cid).subscribe({
+ next: (kyc: any) => openDialog(kyc?.id?.toString() || '', kyc?.panNumber),
+ error: () => openDialog('')
+ });
}
removeSignatoryAt(index: number): void {
diff --git a/src/app/clients/client-stepper/client-general-step/din-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/din-verify-dialog.component.ts
new file mode 100644
index 000000000..8aa41127b
--- /dev/null
+++ b/src/app/clients/client-stepper/client-general-step/din-verify-dialog.component.ts
@@ -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: `
+
+
+ DIN Verification
+
+
+
+ @if (!verifying && !verified && !failed && !errorOccurred) {
+
+
+
+
+ {{ signatoryName }}
+ CIF: {{ cifNumber }} · PAN: {{ panNumber }}
+
+
+ @if (entityName) {
+
+
+
+ Entity
+ {{ entityName }}
+
+
+ }
+
+
+ Enter the Director Identification Number (DIN) 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.
+
+
+ DIN Number
+ badge
+
+ 8-digit DIN number from MCA
+
+
+ }
+
+
+ @if (verifying) {
+
+
+
{{ progress }}%
+
+ @for (step of progressSteps; track step.label; let i = $index) {
+
+
+ {{ step.label }}
+
+ }
+
+
+ Verifying DIN {{ dinNumber }} for {{ signatoryName }}
+ against MCA (Ministry of Corporate Affairs) records.
+
+
+ }
+
+
+ @if (!verifying && verified) {
+
+
+
+
+
VERIFIED
+
DIN Verified Successfully
+
+ DIN {{ dinNumber }} for
+ {{ signatoryName }} has been verified against
+ MCA records.
+
+
+
+
+ @if (companies.length > 0) {
+
+
+
+ Associated Companies ({{ companies.length }})
+
+
+ @if (companyMatch) {
+
+
+
+
Entity Match Found
+
{{ entityName }} matches {{ matchedCompany }}
+
+
+ } @else {
+
+
+
+
No Entity Match
+
{{ entityName }} does not match any of the director's associated companies.
+
+
+ }
+
+
+ @for (company of companies; track company.companyName || $index) {
+
+ @if (company._matched) {
+
check_circle
+ } @else {
+
radio_button_unchecked
+ }
+
+ {{ company.companyName }}
+
+ @if (company.cin) { CIN: {{ company.cin }} }
+ @if (company.cin && company.designation) { · }
+ @if (company.designation) { {{ company.designation }} }
+ @if ((company.cin || company.designation) && company.appointmentDate) { · }
+ @if (company.appointmentDate) { Since: {{ company.appointmentDate }} }
+
+
+
+ }
+
+
+ }
+
+ }
+
+
+ @if (!verifying && failed) {
+
+
+
+
+
FAILED
+
DIN Verification Failed
+
+ DIN {{ dinNumber }} could not be verified.
+
+ @if (failReason) {
+
+
+ {{ failReason }}
+
+ }
+
+ }
+
+
+ @if (!verifying && errorOccurred) {
+
+
+
Verification failed. Please try again.
+
{{ errorMessage }}
+
+ }
+
+
+
+ @if (!verifying && !verified && !failed && !errorOccurred) {
+ Skip
+
+ Verify DIN
+
+ }
+ @if (!verifying && verified) {
+
+ Done
+
+ }
+ @if (!verifying && failed) {
+ Retry
+
+ Close
+
+ }
+ @if (!verifying && errorOccurred) {
+ Retry
+ Close
+ }
+
+ `,
+ 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);
+ 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);
+ }
+ }
+}
diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss
index 2b0ca9c9a..443be4642 100644
--- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss
+++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss
@@ -38,7 +38,7 @@ $muted: #64748b;
&__sub {
font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
+ color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
@@ -173,7 +173,7 @@ $muted: #64748b;
}
// Responsive
-@media (max-width: 768px) {
+@media (width <= 768px) {
.addr-grid--2, .addr-grid--3 {
grid-template-columns: 1fr;
}
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
index daee1a515..429978f87 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
@@ -305,23 +305,85 @@
-
-
- GSTIN Number
-
- @if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
- Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)
- }
-
+
+
+ @if (gstinVerifyStatus === 'VERIFIED') {
+
+
+
+ @if (gstinVerifyName) {
+
+ Trade Name / Legal Name
+ {{ gstinVerifyName }}
+
+ }
+ @if (gstinVerifyState) {
+
+ State
+ {{ gstinVerifyState }}
+
+ }
+
+ Verified On
+ {{ gstinVerifiedOn }}
+
+
+
+ }
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
index 857797484..e5b944138 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
@@ -263,6 +263,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
entPanVerifyName = '';
entPanVerifiedOn = '';
+ /** GSTIN verification */
+ gstinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
+ gstinVerifying = false;
+ gstinVerifyName = '';
+ gstinVerifiedOn = '';
+ gstinVerifyState = '';
+
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
cinVerifying = false;
@@ -480,6 +487,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (data.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
}
+ if (data.gstinVerified) {
+ this.gstinVerifyStatus = 'VERIFIED';
+ this.gstinVerifyName = data.gstinTradeName || data.gstinLegalName || '';
+ this.gstinVerifyState = data.gstinState || '';
+ const rawGstinDate = data.gstinVerifiedOn;
+ if (Array.isArray(rawGstinDate) && rawGstinDate.length >= 3) {
+ this.gstinVerifiedOn = `${rawGstinDate[2]?.toString().padStart(2, '0')}/${rawGstinDate[1]?.toString().padStart(2, '0')}/${rawGstinDate[0]}`;
+ } else if (rawGstinDate) {
+ this.gstinVerifiedOn = rawGstinDate;
+ }
+ }
}
// Load existing photo & signature
this.loadExistingPhoto();
@@ -772,6 +790,68 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
);
}
+ /** Verify GSTIN via API */
+ verifyGstin() {
+ if (!this.clientId) return;
+ const gstin = this.kycForm.value.gstinNumber;
+ if (!gstin || this.kycForm.get('gstinNumber')?.invalid) return;
+
+ this.gstinVerifying = true;
+ const dateFormat = this.settingsService.dateFormat;
+
+ const payload: any = {
+ locale: 'en',
+ kycType: 1,
+ riskCategory: this.kycForm.value.riskCategory || 1,
+ gstinNumber: gstin,
+ dateFormat,
+ panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
+ aadhaarNumberMasked: 'XXXXXXXX0000'
+ };
+
+ this.saveKycAndDo(
+ payload,
+ () => {
+ this.clientsService.verifyGSTIN(this.clientId, this.kycId).subscribe({
+ next: (res: any) => {
+ this.gstinVerifying = false;
+ const changes = res?.changes || res;
+ if (changes?.gstinVerified === true) {
+ this.gstinVerifyStatus = 'VERIFIED';
+ this.gstinVerifyName = changes?.gstinTradeName || changes?.gstinLegalName || '';
+ this.gstinVerifyState = changes?.gstinState || '';
+ const rawDate = changes?.gstinVerifiedOn;
+ if (Array.isArray(rawDate)) {
+ this.gstinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
+ } else {
+ const now = new Date();
+ this.gstinVerifiedOn =
+ rawDate ||
+ `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
+ }
+ } else {
+ this.gstinVerifyStatus = 'FAILED';
+ this.showPanErrorAlert(
+ changes?.gstinVerificationMessage || 'GSTIN verification failed. Please check the GSTIN number and try again.'
+ );
+ }
+ },
+ error: (err: any) => {
+ this.gstinVerifying = false;
+ this.gstinVerifyStatus = 'FAILED';
+ this.showPanErrorAlert(
+ err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'GSTIN verification failed.'
+ );
+ }
+ });
+ },
+ (msg) => {
+ this.gstinVerifying = false;
+ this.showPanErrorAlert(msg);
+ }
+ );
+ }
+
/** Verify CIN (dummy — no real API) */
verifyCin() {
const cin = this.kycForm.value.cinNumber;
diff --git a/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss
index b856b0a44..4086f3fc6 100644
--- a/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss
+++ b/src/app/clients/client-stepper/client-kyc-step/image-crop-dialog/image-crop-dialog.component.scss
@@ -158,7 +158,7 @@ $navy: #0f172a;
}
}
-@media (max-width: 600px) {
+@media (width <= 600px) {
.crop-workspace {
flex-direction: column;
}
diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss
index 6fcd04dc7..acddf86ee 100644
--- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss
+++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.scss
@@ -32,7 +32,7 @@
&__sub {
font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
+ color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
@@ -51,7 +51,7 @@
padding: 20px 24px;
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
border-radius: 10px;
- border: 1px solid rgba(0, 0, 0, 0.06);
+ border: 1px solid rgb(0 0 0 / 6%);
}
.profile-avatar {
@@ -96,9 +96,9 @@
font-weight: 500;
padding: 3px 10px;
border-radius: 20px;
- background: rgba(255, 255, 255, 0.7);
+ background: rgb(255 255 255 / 70%);
color: #555;
- border: 1px solid rgba(0, 0, 0, 0.08);
+ border: 1px solid rgb(0 0 0 / 8%);
.chip-icon {
font-size: 10px;
@@ -120,7 +120,7 @@
/* ── Info Cards ── */
.info-card {
- border: 1px solid rgba(0, 0, 0, 0.08);
+ border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
overflow: hidden;
background: #fff;
@@ -132,7 +132,7 @@
gap: 8px;
padding: 12px 20px;
background: #fafafa;
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ border-bottom: 1px solid rgb(0 0 0 / 6%);
font-size: 14px;
font-weight: 600;
color: #333;
@@ -177,8 +177,8 @@
flex-direction: column;
gap: 4px;
padding: 12px 16px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.04);
- border-right: 1px solid rgba(0, 0, 0, 0.04);
+ border-bottom: 1px solid rgb(0 0 0 / 4%);
+ border-right: 1px solid rgb(0 0 0 / 4%);
&:nth-child(3n) {
border-right: none;
@@ -195,7 +195,7 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
- color: rgba(0, 0, 0, 0.45);
+ color: rgb(0 0 0 / 45%);
}
.preview-value {
@@ -204,7 +204,7 @@
color: #1a1a1a;
&.no-data {
- color: rgba(0, 0, 0, 0.3);
+ color: rgb(0 0 0 / 30%);
font-style: italic;
font-weight: 400;
}
@@ -239,7 +239,7 @@
border-collapse: collapse;
tr {
- border-bottom: 1px solid rgba(0, 0, 0, 0.04);
+ border-bottom: 1px solid rgb(0 0 0 / 4%);
&:last-child {
border-bottom: none;
@@ -254,7 +254,7 @@
.info-label {
width: 40%;
font-size: 13px;
- color: rgba(0, 0, 0, 0.5);
+ color: rgb(0 0 0 / 50%);
font-weight: 500;
padding-right: 16px;
}
@@ -278,17 +278,17 @@
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
- color: rgba(0, 0, 0, 0.55);
+ color: rgb(0 0 0 / 55%);
text-align: left;
text-transform: uppercase;
letter-spacing: 0.3px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+ border-bottom: 1px solid rgb(0 0 0 / 8%);
}
}
tbody {
tr {
- border-bottom: 1px solid rgba(0, 0, 0, 0.04);
+ border-bottom: 1px solid rgb(0 0 0 / 4%);
&:last-child {
border-bottom: none;
@@ -349,7 +349,7 @@
.address-block {
margin-bottom: 16px;
padding-bottom: 16px;
- border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
+ border-bottom: 1px dashed rgb(0 0 0 / 8%);
&:last-child {
margin-bottom: 0;
@@ -381,7 +381,7 @@
justify-content: flex-end;
gap: 12px;
padding: 20px 0 4px;
- border-top: 1px solid rgba(0, 0, 0, 0.08);
+ border-top: 1px solid rgb(0 0 0 / 8%);
margin-top: 8px;
}
@@ -394,23 +394,23 @@
}
.cancel-btn {
- color: rgba(0, 0, 0, 0.6);
+ color: rgb(0 0 0 / 60%);
height: 44px;
}
/* ── Responsive ── */
-@media (max-width: 960px) {
+@media (width <= 960px) {
.preview-grid {
grid-template-columns: 1fr 1fr;
}
.preview-field {
- &:nth-child(3n) { border-right: 1px solid rgba(0, 0, 0, 0.04); }
+ &:nth-child(3n) { border-right: 1px solid rgb(0 0 0 / 4%); }
&:nth-child(2n) { border-right: none; }
}
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.profile-card {
flex-direction: column;
text-align: center;
diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.scss b/src/app/clients/clients-view/address-tab/address-tab.component.scss
index 8166ba235..65b357191 100644
--- a/src/app/clients/clients-view/address-tab/address-tab.component.scss
+++ b/src/app/clients/clients-view/address-tab/address-tab.component.scss
@@ -92,7 +92,7 @@ $red-light: #fce8e6;
margin-bottom: 12px !important;
border-radius: 10px !important;
border: 1px solid $gray-200 !important;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06) !important;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 6%) !important;
&::ng-deep .mat-expansion-panel-body {
padding: 0 20px 20px !important;
@@ -194,11 +194,11 @@ $red-light: #fce8e6;
border-radius: 8px;
overflow: hidden;
- @media (max-width: 768px) {
+ @media (width <= 768px) {
grid-template-columns: repeat(2, 1fr);
}
- @media (max-width: 480px) {
+ @media (width <= 480px) {
grid-template-columns: 1fr;
}
}
@@ -233,7 +233,7 @@ $red-light: #fce8e6;
word-break: break-word;
&--mono {
- font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px;
letter-spacing: 0.5px;
}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
index 813399750..efd92a83b 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
@@ -24,52 +24,108 @@
-
-
-
-
-
-
- {{ 'labels.inputs.Account Number' | translate }}
-
-
- @for (account of allAccounts; track account.accountNo) {
-
- {{ account.accountNo }} ({{ account.productName }})
-
- }
-
-
-
-
-
-
- {{ 'labels.inputs.Client ID' | translate }}
- {{ clientId }}
-
-
-
-
- {{ 'labels.inputs.Client Name' | translate }}
- {{ clientName }}
-
-
-
-
- {{ 'labels.inputs.Account Type' | translate }}
- {{ selectedAccountType || '—' }}
-
+
+
+
+
+ @if (ckycNumber) {
+
+ } @else {
+
+ }
-
-
+
+
+ @if (ckycNumber) {
+ SYNCED WITH CERSAI
+ } @else {
+ PENDING CERSAI SYNC
+ }
+
+ @if (ckycNumber) {
+
CKYC Registry Number
+
{{ ckycNumber }}
+
+ @if (ckycUploadedOn) {
+
+
+ Uploaded on {{ ckycUploadedOn }}
+
+ }
+ @if (ckycBatchId) {
+ ·
+
+
+ Batch {{ ckycBatchId }}
+
+ }
+
+ } @else {
+
This client has not yet been synced to the CERSAI registry
+
+ Collect all 4 artefacts below, then create a batch from the Record Master to upload.
+
+ }
+
+
+
+ @if (ckycNumber) {
+
+
+ Live
+
+ }
+
+
+
+
+
+
+ CIF Number
+
+ {{ cifNumber || clientId }}
+
+
+
+
+ Full Name
+
+ {{ clientName || '—' }}
+
+
+
+
+ Email
+
+ @if (clientEmail) {
+ {{ clientEmail }}
+ } @else {
+ No email on file
+ }
+
+
+
+
+ Mobile
+
+ @if (clientMobile) {
+ {{ clientMobile }}
+ } @else {
+ No mobile on file
+ }
+
+
{{ 'labels.heading.CKYC Documents' | translate }}
+ @if (checkingExisting) {
+
+
+ Checking for already-uploaded artefacts…
+
+ }
+
@@ -78,6 +134,41 @@
home
{{ 'labels.inputs.Address Proof Document' | translate }}
+
+ @if (existing.ADDRESS_PROOF.present) {
+
+
+
+
+ Already available
+ from {{ existing.ADDRESS_PROOF.source }}
+
+
+
+
+ {{ existing.ADDRESS_PROOF.name }}
+ @if (existing.ADDRESS_PROOF.documentKey) {
+ {{ existing.ADDRESS_PROOF.documentKey }}
+ }
+ @if (existing.ADDRESS_PROOF.fileCount && existing.ADDRESS_PROOF.fileCount > 0) {
+
+ {{ existing.ADDRESS_PROOF.fileCount }} {{ existing.ADDRESS_PROOF.fileCount === 1 ? 'file' : 'files' }}
+
+ }
+
+
+
+ {{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
+
+
+ }
+
+ @if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF
}
+ }
@@ -147,6 +239,41 @@
photo_camera
{{ 'labels.inputs.Photo Proof Document' | translate }}
+
+ @if (existing.PHOTO_PROOF.present) {
+
+
+
+
+ Already available
+ from {{ existing.PHOTO_PROOF.source }}
+
+
+
+
+ {{ existing.PHOTO_PROOF.name }}
+ @if (existing.PHOTO_PROOF.documentKey) {
+ {{ existing.PHOTO_PROOF.documentKey }}
+ }
+ @if (existing.PHOTO_PROOF.fileCount && existing.PHOTO_PROOF.fileCount > 0) {
+
+ {{ existing.PHOTO_PROOF.fileCount }} {{ existing.PHOTO_PROOF.fileCount === 1 ? 'file' : 'files' }}
+
+ }
+
+
+
+ {{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
+
+
+ }
+
+ @if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF
}
+ }
@@ -216,6 +344,32 @@
person
{{ 'labels.inputs.Image of Client' | translate }}
+
+ @if (existing.CLIENT_IMAGE.present) {
+
+
+
+
+ Already available
+ from {{ existing.CLIENT_IMAGE.source }}
+
+
+ @if (existing.CLIENT_IMAGE.previewUrl) {
+
+ }
+
+
+ {{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
+
+
+ }
+
+ @if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG
}
+ }
@@ -285,6 +440,36 @@
draw
{{ 'labels.inputs.Signature of Client' | translate }}
+
+ @if (existing.CLIENT_SIGNATURE.present) {
+
+
+
+
+ Already available
+ from {{ existing.CLIENT_SIGNATURE.source }}
+
+
+ @if (existing.CLIENT_SIGNATURE.previewUrl) {
+
+ }
+
+
+ {{ existing.CLIENT_SIGNATURE.name }}
+
+
+
+ {{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
+
+
+ }
+
+ @if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG
}
+ }
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
index 1b5d6650d..efcb3347e 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
@@ -49,37 +49,236 @@
line-height: 18px;
}
-.ckyc-info-card {
- margin-bottom: 24px;
+/* ===================== CERSAI Sync Hero ===================== */
+.cersai-hero {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 22px 26px;
+ border-radius: 16px;
+ overflow: hidden;
+ margin-bottom: 18px;
+ color: #fff;
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.1);
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: -60%;
+ right: -10%;
+ width: 320px;
+ height: 320px;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 70%);
+ pointer-events: none;
+ }
+
+ &--synced {
+ background: linear-gradient(135deg, #0f6e56 0%, #1d9e75 55%, #2fb88c 100%);
+ }
+
+ &--pending {
+ background: linear-gradient(135deg, #8a5a14 0%, #ba7517 55%, #d4892f 100%);
+ }
+
+ &__left {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ min-width: 0;
+ position: relative;
+ flex: 1;
+ }
+
+ &__icon {
+ width: 60px;
+ height: 60px;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+ flex-shrink: 0;
+ backdrop-filter: blur(10px);
+ }
+
+ &__text {
+ min-width: 0;
+ flex: 1;
+ }
+
+ &__tag {
+ display: inline-block;
+ padding: 4px 12px;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.22);
+ border: 1px solid rgba(255, 255, 255, 0.4);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ backdrop-filter: blur(8px);
+ }
+
+ &__ckyc-label {
+ font-size: 11px;
+ font-weight: 600;
+ opacity: 0.9;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin: 10px 0 4px;
+ }
+
+ &__ckyc-number {
+ font-size: 24px;
+ font-weight: 800;
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ letter-spacing: 0.5px;
+ line-height: 1.1;
+ margin-bottom: 8px;
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ opacity: 0.95;
+ flex-wrap: wrap;
+
+ fa-icon { font-size: 11px; opacity: 0.9; margin-right: 3px; }
+ strong { font-weight: 700; }
+ }
+
+ &__sep { opacity: 0.5; }
+
+ &__hint {
+ font-size: 12px;
+ opacity: 0.9;
+ line-height: 1.5;
+ max-width: 480px;
+ margin-top: 4px;
+ }
+
+ &__pulse {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 14px;
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ backdrop-filter: blur(8px);
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+ }
+
+ &__pulse-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #9fe1cb;
+ box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7);
+ animation: cersai-pulse 2s infinite;
+ }
}
+@keyframes cersai-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0.7); }
+ 70% { box-shadow: 0 0 0 10px rgba(159, 225, 203, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(159, 225, 203, 0); }
+}
+
+/* ===================== Client info grid ===================== */
.ckyc-info-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 16px;
- align-items: start;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 22px;
}
.ckyc-info-item {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 6px;
+ padding: 14px 16px;
+ background: #fff;
+ border: 1px solid #e8eaed;
+ border-radius: 12px;
+ min-width: 0;
+ transition: all 0.15s;
+
+ &:hover {
+ border-color: #c6dafc;
+ box-shadow: 0 2px 8px rgba(26, 115, 232, 0.06);
+ }
+}
+
+@media (width <= 900px) {
+ .ckyc-info-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .cersai-hero {
+ flex-direction: column;
+ align-items: flex-start;
+
+ &__left { width: 100%; }
+ &__icon { width: 52px; height: 52px; font-size: 22px; }
+ &__ckyc-number { font-size: 20px; }
+ }
+}
+
+@media (width <= 500px) {
+ .ckyc-info-grid {
+ grid-template-columns: 1fr;
+ }
}
.ckyc-info-label {
- font-size: 11px;
- font-weight: 600;
- color: #888;
+ font-size: 10px;
+ font-weight: 700;
+ color: #5f6368;
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.06em;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ fa-icon { color: #1a73e8; font-size: 11px; }
}
.ckyc-info-value {
- font-size: 14px;
- color: #333;
- min-height: 20px;
+ font-size: 13px;
+ color: #202124;
+ font-weight: 600;
+ min-height: 18px;
+ word-break: break-word;
+
+ &--mono {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ font-size: 12px;
+ color: #1a73e8;
+ letter-spacing: 0.3px;
+ }
+
+ &--missing {
+ color: #9aa0a6;
+ font-weight: 500;
+ font-style: italic;
+ }
}
+.ckyc-info-hint { display: none; }
+
.ckyc-section-title {
margin: 24px 0 12px;
font-weight: 600;
@@ -87,31 +286,58 @@
.ckyc-upload-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
+ align-items: stretch;
}
.ckyc-upload-card {
+ border-radius: 12px !important;
+ border: 1px solid #e8eaed;
+ box-shadow: none !important;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ display: flex;
+ flex-direction: column;
+ min-height: 240px;
+
+ &:hover {
+ border-color: #c6dafc;
+ box-shadow: 0 2px 10px rgba(26, 115, 232, 0.06) !important;
+ }
+
.mat-mdc-card-content {
- padding: 16px;
+ padding: 18px 20px !important;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
}
}
.ckyc-upload-header {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 10px;
font-weight: 600;
font-size: 14px;
- margin-bottom: 8px;
+ color: #202124;
+ padding-bottom: 12px;
+ margin-bottom: 0;
+ border-bottom: 1px solid #f1f3f4;
}
.ckyc-upload-icon {
- font-size: 20px;
- height: 20px;
- width: 20px;
- color: #1976d2;
+ font-size: 18px;
+ height: 32px;
+ width: 32px;
+ color: #fff;
+ background: linear-gradient(135deg, #1a73e8, #4285f4);
+ border-radius: 8px;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 6px rgba(26, 115, 232, 0.25);
+ flex-shrink: 0;
}
.ckyc-upload-hint {
@@ -163,6 +389,12 @@
}
}
+@media (width <= 900px) {
+ .ckyc-upload-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
@media (width <= 600px) {
.ckyc-info-grid {
grid-template-columns: 1fr;
@@ -180,3 +412,149 @@
width: 100%;
}
}
+
+/* ===================== Already-available banners ===================== */
+
+.ckyc-existing-loading {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ background: #e8f0fe;
+ color: #0c447c;
+ border-radius: 8px;
+ font-size: 12px;
+ margin-bottom: 14px;
+
+ mat-progress-spinner { display: inline-block; }
+}
+
+.ckyc-existing {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px 14px 12px;
+ background: #f0fbf6;
+ border: 1px solid #cfecdd;
+ border-radius: 10px;
+ margin-top: 14px;
+ flex: 1;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 3px;
+ background: #1d9e75;
+ border-radius: 10px 0 0 10px;
+ }
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ strong {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ color: #085041;
+ line-height: 1.2;
+ }
+ }
+
+ &__ok {
+ font-size: 20px;
+ color: #1d9e75;
+ flex-shrink: 0;
+ }
+
+ &__source {
+ display: block;
+ font-size: 11px;
+ color: #0f6e56;
+ margin-top: 2px;
+ font-weight: 500;
+ }
+
+ &__meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: #085041;
+ flex-wrap: wrap;
+ padding: 8px 10px;
+ background: #fff;
+ border: 1px solid #cfecdd;
+ border-radius: 8px;
+
+ > span:first-of-type {
+ font-weight: 600;
+ }
+
+ fa-icon { color: #1d9e75; font-size: 12px; }
+ }
+
+ &__key {
+ font-family: 'SF Mono', Consolas, monospace;
+ font-size: 11px;
+ background: #e1f5ee;
+ color: #0f6e56;
+ padding: 2px 8px;
+ border-radius: 10px;
+ border: 1px solid #cfecdd;
+ font-weight: 600;
+ }
+
+ &__count {
+ font-size: 10px;
+ font-weight: 700;
+ background: #1d9e75;
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ }
+
+ &__thumb {
+ width: 100px;
+ height: 100px;
+ border-radius: 10px;
+ border: 2px solid #fff;
+ outline: 1px solid #cfecdd;
+ background: #fff;
+ object-fit: cover;
+ align-self: center;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+
+ &--wide {
+ width: 200px;
+ height: 80px;
+ object-fit: contain;
+ }
+ }
+
+ &__replace {
+ align-self: stretch;
+ margin-top: auto;
+ height: 34px !important;
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ border-radius: 8px !important;
+ border: 1px solid #cfecdd !important;
+ color: #0f6e56 !important;
+ background: #fff !important;
+ padding: 0 12px !important;
+ letter-spacing: 0.2px;
+
+ fa-icon { margin-right: 6px; font-size: 11px; }
+
+ &:hover {
+ background: #e1f5ee !important;
+ border-color: #1d9e75 !important;
+ }
+ }
+}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
index 5f66d01bc..2033c44e8 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
@@ -9,7 +9,7 @@
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
-import { Subject, takeUntil, catchError, of } from 'rxjs';
+import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -19,6 +19,17 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+interface ExistingArtefact {
+ present: boolean;
+ name?: string;
+ source?: string;
+ previewUrl?: string;
+ documentId?: string;
+ documentKey?: string;
+ fileCount?: number;
+ expanded?: boolean;
+}
+
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -41,6 +52,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Top section - read-only client info */
clientId: string;
clientName: string = '';
+ clientEmail: string = '';
+ clientMobile: string = '';
+ cifNumber: string = '';
+ ckycNumber: string = '';
allAccounts: any[] = [];
selectedAccount: any = null;
selectedAccountType: string = '';
@@ -48,6 +63,17 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** State */
loading = true;
ckycData: any = null;
+ ckycUploadedOn = '';
+ ckycBatchId = '';
+ checkingExisting = false;
+
+ /** Pre-existing artefacts detected from client data */
+ existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
+ ADDRESS_PROOF: { present: false },
+ PHOTO_PROOF: { present: false },
+ CLIENT_IMAGE: { present: false },
+ CLIENT_SIGNATURE: { present: false }
+ };
/** File references */
addressProofFile: File | null = null;
@@ -80,13 +106,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
+ this.loadExistingArtefacts();
});
}
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
- if (data?.clientViewData) {
- this.clientName = data.clientViewData.displayName || '';
+ const cv = data?.clientViewData;
+ if (cv) {
+ this.clientName = cv.displayName || '';
+ this.clientEmail = cv.emailAddress || '';
+ this.clientMobile = cv.mobileNo || '';
+ this.cifNumber = cv.accountNo || cv.externalId || '';
}
});
}
@@ -122,8 +153,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
private loadCkycData(): void {
this.loading = true;
+ // Read CKYC registry data from /clients/{id}/kyc — that's where the
+ // SFTP upload flow persists ckycNumber / ckycUploadedOn / ckycBatchId.
this.clientsService
- .getClientCkyc(this.clientId)
+ .getClientKyc(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => {
@@ -134,9 +167,120 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.subscribe((data: any) => {
this.loading = false;
this.ckycData = data;
+ this.ckycNumber = data?.ckycNumber || '';
+ this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
+ this.ckycBatchId = data?.ckycBatchId || '';
});
}
+ private formatJavaDate(value: any): string {
+ if (!value) return '';
+ if (Array.isArray(value) && value.length >= 3) {
+ const [y, m, d] = value;
+ return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
+ }
+ if (typeof value === 'string') return value;
+ return '';
+ }
+
+ /**
+ * Fetch photo (profile image), signature (client document),
+ * ID proof and address proof (client identifiers) so already-uploaded
+ * artefacts are shown here instead of forcing re-upload.
+ */
+ private loadExistingArtefacts(): void {
+ this.checkingExisting = true;
+
+ forkJoin({
+ photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
+ documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
+ identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
+ proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
+ })
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(({ photo, documents, identifiers, proofTypes }) => {
+ this.checkingExisting = false;
+
+ // 1. Photo / image of client — from m_client.image_id
+ if (photo && typeof photo === 'string' && photo.length > 0) {
+ this.existing.CLIENT_IMAGE = {
+ present: true,
+ source: 'Client registration',
+ previewUrl: photo,
+ name: 'Profile photo'
+ };
+ }
+
+ // 2. Signature — client document with name='clientSignature'
+ const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
+ if (signatureDoc) {
+ this.existing.CLIENT_SIGNATURE = {
+ present: true,
+ source: 'Client registration',
+ name: signatureDoc.fileName || 'Client signature',
+ documentId: signatureDoc.id
+ };
+ // Fetch blob for preview
+ this.clientsService
+ .getClientSignatureImage(this.clientId, signatureDoc.id)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (blob: any) => {
+ if (blob) {
+ this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
+ }
+ },
+ error: () => undefined
+ });
+ }
+
+ // 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
+ const idProofIds = new Set
((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
+ const addressProofIds = new Set(
+ (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 " - "
+ // in identities-tab; hide it when it's just that synthetic key.
+ const rawKey = identity?.documentKey || '';
+ const isSyntheticKey = rawKey.startsWith(`${proofName} - `);
+ const common = {
+ present: true,
+ source: 'Identities section',
+ name: proofName,
+ documentKey: isSyntheticKey ? '' : rawKey,
+ documentId: identity?.id,
+ fileCount: (identity?.documents || []).length
+ };
+
+ if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
+ this.existing.PHOTO_PROOF = common;
+ } else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
+ this.existing.ADDRESS_PROOF = common;
+ }
+ }
+ });
+ }
+
+ toggleReplace(documentType: string): void {
+ const entry = (this.existing as any)[documentType];
+ if (entry) entry.expanded = !entry.expanded;
+ }
+
onAccountSelect(accountNo: string): void {
this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null;
this.selectedAccountType = this.selectedAccount?.accountType || '';
@@ -215,5 +359,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
+ // Release object URLs
+ Object.values(this.existing).forEach((e) => {
+ if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
+ });
}
}
diff --git a/src/app/clients/clients-view/clients-view.component.scss b/src/app/clients/clients-view/clients-view.component.scss
index 8db937770..ac77f001c 100644
--- a/src/app/clients/clients-view/clients-view.component.scss
+++ b/src/app/clients/clients-view/clients-view.component.scss
@@ -22,8 +22,8 @@ $accent: #0ea5e9;
background: #fff !important;
border-radius: 10px;
overflow: hidden;
- border: 1px solid rgba(0, 0, 0, 0.06);
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
+ border: 1px solid rgb(0 0 0 / 6%);
+ box-shadow: 0 1px 4px rgb(0 0 0 / 4%);
margin: 0;
padding: 0;
}
@@ -42,10 +42,7 @@ $accent: #0ea5e9;
&::after {
content: '';
position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ inset: 0;
background: repeating-linear-gradient(108deg, transparent 0, transparent 80px, rgb(255 255 255 / 2.5%) 80px, rgb(255 255 255 / 2.5%) 160px);
pointer-events: none;
}
@@ -69,7 +66,7 @@ $accent: #0ea5e9;
height: 72px;
border-radius: 10px;
object-fit: cover;
- border: 2px solid rgba(255, 255, 255, 0.25);
+ border: 2px solid rgb(255 255 255 / 25%);
background: #fff;
}
@@ -84,9 +81,9 @@ $accent: #0ea5e9;
min-width: 26px !important;
padding: 0 !important;
border-radius: 5px !important;
- background: rgba(255, 255, 255, 0.1) !important;
- color: rgba(255, 255, 255, 0.6) !important;
- border: 1px solid rgba(255, 255, 255, 0.15) !important;
+ background: rgb(255 255 255 / 10%) !important;
+ color: rgb(255 255 255 / 60%) !important;
+ border: 1px solid rgb(255 255 255 / 15%) !important;
display: flex !important;
align-items: center;
justify-content: center;
@@ -96,19 +93,19 @@ $accent: #0ea5e9;
i { font-size: 10px; }
&:hover {
- background: rgba(255, 255, 255, 0.2) !important;
+ background: rgb(255 255 255 / 20%) !important;
color: #fff !important;
}
&--danger:hover {
- background: rgba(239, 68, 68, 0.35) !important;
- border-color: rgba(239, 68, 68, 0.45) !important;
+ background: rgb(239 68 68 / 35%) !important;
+ border-color: rgb(239 68 68 / 45%) !important;
}
}
&__sig {
font-size: 10px;
- color: rgba(255, 255, 255, 0.6);
+ color: rgb(255 255 255 / 60%);
cursor: pointer;
font-weight: 500;
@@ -148,23 +145,23 @@ $accent: #0ea5e9;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.4px;
- background: rgba(16, 185, 129, 0.2);
+ background: rgb(16 185 129 / 20%);
color: #6ee7b7;
- border: 1px solid rgba(16, 185, 129, 0.25);
+ border: 1px solid rgb(16 185 129 / 25%);
}
}
.cv-menu-btn {
- color: rgba(255, 255, 255, 0.7) !important;
+ color: rgb(255 255 255 / 70%) !important;
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
padding: 0 !important;
- border: 1px solid rgba(255, 255, 255, 0.15);
+ border: 1px solid rgb(255 255 255 / 15%);
border-radius: 5px !important;
line-height: 28px !important;
- &:hover { background: rgba(255, 255, 255, 0.1); color: #fff !important; }
+ &:hover { background: rgb(255 255 255 / 10%); color: #fff !important; }
.actions-menu {
font-size: 14px !important;
@@ -187,14 +184,14 @@ $accent: #0ea5e9;
align-items: center;
gap: 8px;
padding: 4px 11px;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.13);
+ background: rgb(255 255 255 / 10%);
+ border: 1px solid rgb(255 255 255 / 13%);
border-radius: 5px;
&__label {
font-size: 9px;
font-weight: 700;
- color: rgba(255, 255, 255, 0.4);
+ color: rgb(255 255 255 / 40%);
text-transform: uppercase;
letter-spacing: 0.4px;
}
@@ -203,7 +200,7 @@ $accent: #0ea5e9;
font-size: 12px;
font-weight: 600;
color: #fff;
- font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
letter-spacing: 0.3px;
}
}
@@ -220,19 +217,19 @@ $accent: #0ea5e9;
.cv-cell {
padding: 9px 13px;
- background: rgba(255, 255, 255, 0.07);
+ background: rgb(255 255 255 / 7%);
border-radius: 6px;
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid rgb(255 255 255 / 8%);
transition: background 0.12s;
&:hover {
- background: rgba(255, 255, 255, 0.12);
+ background: rgb(255 255 255 / 12%);
}
&__label {
font-size: 9px;
font-weight: 600;
- color: rgba(255, 255, 255, 0.45);
+ color: rgb(255 255 255 / 45%);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
@@ -253,9 +250,9 @@ $accent: #0ea5e9;
border-radius: 10px;
letter-spacing: 0.3px;
- &--green { background: rgba(16, 185, 129, 0.2); color: #6ee7b7; }
- &--yellow { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
- &--red { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
+ &--green { background: rgb(16 185 129 / 20%); color: #6ee7b7; }
+ &--yellow { background: rgb(245 158 11 / 20%); color: #fcd34d; }
+ &--red { background: rgb(239 68 68 / 20%); color: #fca5a5; }
}
.actions-menu { color: #fff !important; }
@@ -263,7 +260,7 @@ $accent: #0ea5e9;
// ── Tab bar ──
.cv-tabs {
background: #fff;
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ border-bottom: 1px solid rgb(0 0 0 / 6%);
::ng-deep .mat-mdc-tab-link {
font-size: 12px !important;
@@ -292,7 +289,7 @@ $accent: #0ea5e9;
button,
mat-icon,
fa-icon {
- color: rgba(255, 255, 255, 0.4) !important;
+ color: rgb(255 255 255 / 40%) !important;
font-size: 11px !important;
width: 16px !important;
height: 16px !important;
@@ -311,8 +308,8 @@ $accent: #0ea5e9;
// ── Client menu styling ──
::ng-deep .mat-mdc-menu-panel {
border-radius: 10px !important;
- border: 1px solid rgba(0, 0, 0, 0.06) !important;
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important;
+ border: 1px solid rgb(0 0 0 / 6%) !important;
+ box-shadow: 0 8px 30px rgb(0 0 0 / 12%) !important;
min-width: 200px !important;
padding: 6px 0 !important;
@@ -352,17 +349,17 @@ $accent: #0ea5e9;
}
// ── Responsive ──
-@media (max-width: 1024px) {
+@media (width <= 1024px) {
.cv-grid { grid-template-columns: repeat(3, 1fr); }
}
-@media (max-width: 768px) {
+@media (width <= 768px) {
.cv-top { flex-direction: column; align-items: center; text-align: center; }
.cv-name-row { justify-content: center; }
.cv-ids { justify-content: center; }
.cv-grid { grid-template-columns: repeat(2, 1fr); }
}
-@media (max-width: 480px) {
+@media (width <= 480px) {
.cv-grid { grid-template-columns: 1fr; }
}
diff --git a/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.html
new file mode 100644
index 000000000..18999db9c
--- /dev/null
+++ b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.html
@@ -0,0 +1,28 @@
+
+
+{{ title }}
+
+ Drag the corners to crop. The result will be saved as a PNG.
+
+
+
+
+
+ Cancel
+ Use cropped image
+
diff --git a/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.scss b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.scss
new file mode 100644
index 000000000..eb240c96e
--- /dev/null
+++ b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.scss
@@ -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;
+}
diff --git a/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.ts
new file mode 100644
index 000000000..7ea1210f3
--- /dev/null
+++ b/src/app/clients/clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component.ts
@@ -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);
+ data = inject(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;
+ }
+}
diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html
index b4594a106..d97a896bb 100644
--- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html
+++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.html
@@ -6,7 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-