feat: Client & CKYC UI improvements — filters, KYC management, image crop+merge
- Clients list: CKYC icon, CKYC Number column, status/legal form/CKYC filters - CKYC: SFTP upload logs, date formatting, modal z-index fix, non-dismissible dialogs - KYC Management: new standalone module with alert dashboard, notification scheduler, audit inventory, account status - Image cropper: ngx-image-cropper integration for front+back document merge into single PNG - Sidebar: KYC Management menu entry after CKYC Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
79e455c4ed
commit
d72e969dc3
@@ -6,14 +6,565 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="placeholder-content">
|
||||
<fa-icon icon="shield-alt" size="3x" class="placeholder-icon"></fa-icon>
|
||||
<h3>{{ 'labels.heading.CKYC' | translate }}</h3>
|
||||
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
|
||||
<div class="cv-container">
|
||||
<!-- Header -->
|
||||
<div class="cv-header">
|
||||
<div class="cv-header__left">
|
||||
<div class="cv-header__icon"><fa-icon icon="shield-alt"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="cv-header__title">CKYC Verification</h2>
|
||||
<p class="cv-header__sub">Batch · Upload · Match — CERSAI compliance pipeline</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<button class="cv-btn cv-btn--ghost" (click)="fetch()">
|
||||
<fa-icon icon="sync"></fa-icon>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary KPIs -->
|
||||
<div class="cv-kpis">
|
||||
<div class="cv-kpi">
|
||||
<div class="cv-kpi__icon cv-kpi__icon--blue"><fa-icon icon="file-archive"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-kpi__label">Total batches</div>
|
||||
<div class="cv-kpi__value">{{ totalBatches }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-kpi">
|
||||
<div class="cv-kpi__icon cv-kpi__icon--teal"><fa-icon icon="users"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-kpi__label">Total records</div>
|
||||
<div class="cv-kpi__value">{{ totalRecords }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-kpi">
|
||||
<div class="cv-kpi__icon cv-kpi__icon--amber"><fa-icon icon="hourglass-half"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-kpi__label">Pending upload</div>
|
||||
<div class="cv-kpi__value">{{ totalPending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-kpi">
|
||||
<div class="cv-kpi__icon cv-kpi__icon--green"><fa-icon icon="check-double"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-kpi__label">Uploaded</div>
|
||||
<div class="cv-kpi__value">{{ totalUploaded }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-kpi">
|
||||
<div class="cv-kpi__icon cv-kpi__icon--purple"><fa-icon icon="database"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-kpi__label">Total size</div>
|
||||
<div class="cv-kpi__value">{{ formatBytes(totalSize) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cv-filters">
|
||||
<div class="cv-filters__grid">
|
||||
<div class="cv-field">
|
||||
<label class="cv-label"><fa-icon icon="calendar"></fa-icon> From date</label>
|
||||
<input type="date" class="cv-input" [(ngModel)]="filters.fromDate" />
|
||||
</div>
|
||||
<div class="cv-field">
|
||||
<label class="cv-label"><fa-icon icon="calendar"></fa-icon> To date</label>
|
||||
<input type="date" class="cv-input" [(ngModel)]="filters.toDate" />
|
||||
</div>
|
||||
<div class="cv-field">
|
||||
<label class="cv-label"><fa-icon icon="filter"></fa-icon> Upload status</label>
|
||||
<select class="cv-input" [(ngModel)]="filters.uploadedToCersai">
|
||||
<option value="">All batches</option>
|
||||
<option value="false">Pending upload</option>
|
||||
<option value="true">Uploaded</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-filters__actions">
|
||||
<button class="cv-btn cv-btn--ghost" (click)="resetFilters()">
|
||||
<fa-icon icon="times"></fa-icon> <span>Reset</span>
|
||||
</button>
|
||||
<button class="cv-btn cv-btn--primary" (click)="fetch()">
|
||||
<fa-icon icon="search"></fa-icon> <span>Apply filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section tabs -->
|
||||
<div class="cv-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="cv-tab"
|
||||
[class.cv-tab--active]="activeSection === 'zip'"
|
||||
(click)="activeSection = 'zip'"
|
||||
>
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
<span>ZIP Batch Details</span>
|
||||
<span class="cv-tab__badge cv-tab__badge--blue">Phase 2</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cv-tab"
|
||||
[class.cv-tab--active]="activeSection === 'sftp'"
|
||||
(click)="activeSection = 'sftp'"
|
||||
>
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
<span>SFTP Upload & ACK · API Match</span>
|
||||
@if (totalPending > 0) {
|
||||
<span class="cv-tab__count">{{ totalPending }}</span>
|
||||
}
|
||||
<span class="cv-tab__badge cv-tab__badge--amber">Phase 3-4</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 1: ZIP Details -->
|
||||
@if (activeSection === 'zip') {
|
||||
<div class="cv-section">
|
||||
<div class="cv-section__head">
|
||||
<div class="cv-section__title">
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
<div>
|
||||
<h3>ZIP Batch Details</h3>
|
||||
<p>CERSAI ZIP archives grouped by creation date</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cv-badge cv-badge--blue">Phase 2</span>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cv-state cv-state--loading">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Loading batches…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error && !loading) {
|
||||
<div class="cv-state cv-state--error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && !error && groups.length === 0) {
|
||||
<div class="cv-state cv-state--empty">
|
||||
<fa-icon icon="file-archive" size="2x"></fa-icon>
|
||||
<h4>No batches found</h4>
|
||||
<p>Create a batch from the Record Master to see it here.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && groups.length > 0) {
|
||||
<div class="cv-groups">
|
||||
@for (group of groups; track group.dateLabel) {
|
||||
<div class="cv-group" [class.cv-group--open]="group.expanded">
|
||||
<button class="cv-group__head" (click)="toggleGroup(group)">
|
||||
<div class="cv-group__left">
|
||||
<fa-icon [icon]="group.expanded ? 'chevron-down' : 'chevron-right'" class="cv-group__chev"></fa-icon>
|
||||
<div class="cv-group__date-pill">
|
||||
<fa-icon icon="calendar-check"></fa-icon>
|
||||
<span>{{ group.dateLabel }}</span>
|
||||
</div>
|
||||
<div class="cv-group__summary">
|
||||
<span><strong>{{ group.batchCount }}</strong> batches</span>
|
||||
<span class="cv-group__sep">·</span>
|
||||
<span><strong>{{ group.totalRecordCount }}</strong> records</span>
|
||||
<span class="cv-group__sep">·</span>
|
||||
<span>{{ formatBytes(group.totalSizeBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-group__right">
|
||||
@if (group.pendingCount > 0) {
|
||||
<span class="cv-pill cv-pill--amber">
|
||||
<fa-icon icon="hourglass-half"></fa-icon> {{ group.pendingCount }} pending
|
||||
</span>
|
||||
}
|
||||
@if (group.uploadedCount > 0) {
|
||||
<span class="cv-pill cv-pill--green">
|
||||
<fa-icon icon="check-double"></fa-icon> {{ group.uploadedCount }} uploaded
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@if (group.expanded) {
|
||||
<div class="cv-group__body">
|
||||
<table class="cv-batches">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Batch ID</th>
|
||||
<th>Filename</th>
|
||||
<th class="cv-batches__num">Records</th>
|
||||
<th class="cv-batches__num">Size</th>
|
||||
<th>Status</th>
|
||||
<th class="cv-batches__actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (batch of group.batches; track trackBatch($index, batch)) {
|
||||
<tr>
|
||||
<td class="cv-batches__bid">
|
||||
<fa-icon icon="tag"></fa-icon>
|
||||
<span>{{ batch.batchId }}</span>
|
||||
</td>
|
||||
<td class="cv-batches__file">
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
<span>{{ batch.filename }}</span>
|
||||
</td>
|
||||
<td class="cv-batches__num">{{ batch.recordCount }}</td>
|
||||
<td class="cv-batches__num">{{ formatBytes(batch.sizeBytes) }}</td>
|
||||
<td>
|
||||
@if (batch.uploadedToCersai) {
|
||||
<span class="cv-pill cv-pill--green">
|
||||
<fa-icon icon="check-circle"></fa-icon> Uploaded
|
||||
</span>
|
||||
} @else {
|
||||
<span class="cv-pill cv-pill--amber">
|
||||
<fa-icon icon="hourglass-half"></fa-icon> Pending
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="cv-batches__actions">
|
||||
<button
|
||||
class="cv-action-btn"
|
||||
(click)="openManifest(batch, $event)"
|
||||
title="View manifest"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
@if (batch.documentId) {
|
||||
<button
|
||||
class="cv-action-btn cv-action-btn--primary"
|
||||
(click)="downloadZip(batch, $event)"
|
||||
title="Download ZIP"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
<!-- SECTION 2: SFTP Upload + API Match -->
|
||||
@if (activeSection === 'sftp') {
|
||||
<div class="cv-section">
|
||||
<div class="cv-section__head">
|
||||
<div class="cv-section__title">
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
<div>
|
||||
<h3>SFTP Upload & ACK · API Match</h3>
|
||||
<p>CERSAI SFTP transfer history with ACK receipts and per-batch logs</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cv-badge cv-badge--amber">Phase 3-4</span>
|
||||
</div>
|
||||
|
||||
<div class="cv-sftp-kpis">
|
||||
<div class="cv-sftp-kpi cv-sftp-kpi--green">
|
||||
<div class="cv-sftp-kpi__icon"><fa-icon icon="check-circle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-sftp-kpi__value">{{ sftpSuccessCount }}</div>
|
||||
<div class="cv-sftp-kpi__label">Uploaded successfully</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-sftp-kpi cv-sftp-kpi--red">
|
||||
<div class="cv-sftp-kpi__icon"><fa-icon icon="times-circle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-sftp-kpi__value">{{ sftpFailedCount }}</div>
|
||||
<div class="cv-sftp-kpi__label">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cv-sftp-kpi cv-sftp-kpi--amber">
|
||||
<div class="cv-sftp-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></div>
|
||||
<div>
|
||||
<div class="cv-sftp-kpi__value">{{ sftpPendingCount }}</div>
|
||||
<div class="cv-sftp-kpi__label">Pending upload</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cv-state cv-state--loading">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Loading logs…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && sftpLogs.length === 0) {
|
||||
<div class="cv-state cv-state--empty">
|
||||
<fa-icon icon="cloud-upload-alt" size="2x"></fa-icon>
|
||||
<h4>No SFTP upload history yet</h4>
|
||||
<p>Upload a batch from the Record Master to see CERSAI transfer logs here.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && sftpLogs.length > 0) {
|
||||
<div class="cv-sftp-logs">
|
||||
@for (log of sftpLogs; track log.id) {
|
||||
<div
|
||||
class="cv-sftp-log"
|
||||
[class.cv-sftp-log--success]="isSftpSuccess(log)"
|
||||
[class.cv-sftp-log--failed]="isSftpFailed(log)"
|
||||
>
|
||||
<div class="cv-sftp-log__icon">
|
||||
@if (isSftpSuccess(log)) {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
} @else if (isSftpFailed(log)) {
|
||||
<fa-icon icon="times-circle"></fa-icon>
|
||||
} @else {
|
||||
<fa-icon icon="hourglass-half"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="cv-sftp-log__body">
|
||||
<div class="cv-sftp-log__head">
|
||||
<span class="cv-sftp-log__bid">
|
||||
<fa-icon icon="tag"></fa-icon> {{ log.batchId }}
|
||||
</span>
|
||||
@if (isSftpSuccess(log)) {
|
||||
<span class="cv-pill cv-pill--green">
|
||||
<fa-icon icon="check-circle"></fa-icon> Upload successful
|
||||
</span>
|
||||
} @else if (isSftpFailed(log)) {
|
||||
<span class="cv-pill cv-pill--red">
|
||||
<fa-icon icon="times-circle"></fa-icon> Upload failed
|
||||
</span>
|
||||
} @else {
|
||||
<span class="cv-pill cv-pill--amber">
|
||||
<fa-icon icon="hourglass-half"></fa-icon> Pending
|
||||
</span>
|
||||
}
|
||||
<span class="cv-sftp-log__time">
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
{{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cv-sftp-log__meta">
|
||||
<div>
|
||||
<span class="cv-sftp-log__label">Filename</span>
|
||||
<span class="cv-sftp-log__value">{{ log.filename }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-sftp-log__label">Records</span>
|
||||
<span class="cv-sftp-log__value">{{ log.recordCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-sftp-log__label">Size</span>
|
||||
<span class="cv-sftp-log__value">{{ formatBytes(log.sizeBytes) }}</span>
|
||||
</div>
|
||||
@if (log.cersaiReference) {
|
||||
<div>
|
||||
<span class="cv-sftp-log__label">CERSAI Ref</span>
|
||||
<span class="cv-sftp-log__value cv-sftp-log__value--mono">{{ log.cersaiReference }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (log.uploadRemarks) {
|
||||
<div class="cv-sftp-log__remarks" [class.cv-sftp-log__remarks--err]="isSftpFailed(log)">
|
||||
<fa-icon [icon]="isSftpFailed(log) ? 'exclamation-triangle' : 'info-circle'"></fa-icon>
|
||||
<span>{{ log.uploadRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cv-sftp-log__actions">
|
||||
<button class="cv-action-btn" (click)="openManifest(log, $event)" title="View manifest">
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
@if (log.documentId) {
|
||||
<button
|
||||
class="cv-action-btn cv-action-btn--primary"
|
||||
(click)="downloadZip(log, $event)"
|
||||
title="Download ZIP"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Manifest modal -->
|
||||
@if (showManifest) {
|
||||
<div class="cv-modal" (click)="closeManifest()">
|
||||
<div class="cv-modal__card" (click)="$event.stopPropagation()">
|
||||
<div class="cv-modal__header">
|
||||
<div class="cv-modal__title">
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
Batch Manifest
|
||||
</div>
|
||||
<button class="cv-modal__close" (click)="closeManifest()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cv-modal__body">
|
||||
@if (manifestLoading) {
|
||||
<div class="cv-state cv-state--loading">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Loading manifest…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manifestError) {
|
||||
<div class="cv-state cv-state--error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>{{ manifestError }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manifestBatch && !manifestLoading) {
|
||||
<div class="cv-manifest-head">
|
||||
<div class="cv-manifest-head__grid">
|
||||
<div>
|
||||
<span class="cv-kpi__label">Batch ID</span>
|
||||
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.batchId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-kpi__label">Created on</span>
|
||||
<span class="cv-kpi__value">{{ manifestBatch.createdOnLabel || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-kpi__label">Records</span>
|
||||
<span class="cv-kpi__value">{{ manifestBatch.recordCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-kpi__label">Size</span>
|
||||
<span class="cv-kpi__value">{{ formatBytes(manifestBatch.sizeBytes) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cv-kpi__label">Status</span>
|
||||
@if (manifestBatch.uploadedToCersai) {
|
||||
<span class="cv-pill cv-pill--green">
|
||||
<fa-icon icon="check-circle"></fa-icon> Uploaded
|
||||
</span>
|
||||
} @else {
|
||||
<span class="cv-pill cv-pill--amber">
|
||||
<fa-icon icon="hourglass-half"></fa-icon> Pending
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="cv-manifest-head__grid-span">
|
||||
<span class="cv-kpi__label">Filename</span>
|
||||
<span class="cv-kpi__value">{{ manifestBatch.filename }}</span>
|
||||
</div>
|
||||
<div class="cv-manifest-head__grid-span">
|
||||
<span class="cv-kpi__label">Checksum</span>
|
||||
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.checksum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (manifestBatch.manifest?.length > 0) {
|
||||
<div class="cv-manifest-title">
|
||||
<fa-icon icon="list-alt"></fa-icon>
|
||||
Packaged clients ({{ manifestBatch.manifest.length }})
|
||||
</div>
|
||||
<div class="cv-manifest-table-wrap">
|
||||
<table class="cv-manifest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th class="cv-th-c">P</th>
|
||||
<th class="cv-th-c">S</th>
|
||||
<th class="cv-th-c">ID</th>
|
||||
<th class="cv-th-c">Ad</th>
|
||||
<th class="cv-th-r">Total size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of manifestBatch.manifest; track entry.clientId) {
|
||||
<tr [class.cv-manifest-row--complete]="isEntryComplete(entry)">
|
||||
<td>
|
||||
<div class="cv-manifest-client">
|
||||
<strong>{{ entry.displayName || 'Client ' + entry.clientId }}</strong>
|
||||
@if (entry.cifNumber) {
|
||||
<span class="cv-manifest-cif">
|
||||
<fa-icon icon="hashtag"></fa-icon>{{ entry.cifNumber }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="cv-td-c">
|
||||
<span
|
||||
class="cv-dot"
|
||||
[class.cv-dot--ok]="entry.photo?.packaged"
|
||||
[class.cv-dot--miss]="!entry.photo?.packaged"
|
||||
[title]="entry.photo?.zipEntry || 'Missing'"
|
||||
>
|
||||
<fa-icon [icon]="entry.photo?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="cv-td-c">
|
||||
<span
|
||||
class="cv-dot"
|
||||
[class.cv-dot--ok]="entry.signature?.packaged"
|
||||
[class.cv-dot--miss]="!entry.signature?.packaged"
|
||||
[title]="entry.signature?.zipEntry || 'Missing'"
|
||||
>
|
||||
<fa-icon [icon]="entry.signature?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="cv-td-c">
|
||||
<span
|
||||
class="cv-dot"
|
||||
[class.cv-dot--ok]="entry.idProof?.packaged"
|
||||
[class.cv-dot--miss]="!entry.idProof?.packaged"
|
||||
[title]="(entry.idProof?.type || 'ID') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
|
||||
>
|
||||
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="cv-td-c">
|
||||
<span
|
||||
class="cv-dot"
|
||||
[class.cv-dot--ok]="entry.addressProof?.packaged"
|
||||
[class.cv-dot--miss]="!entry.addressProof?.packaged"
|
||||
[title]="(entry.addressProof?.type || 'Address') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
|
||||
>
|
||||
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="cv-td-r cv-manifest-size">
|
||||
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cv-modal__footer">
|
||||
<button class="cv-btn cv-btn--ghost" (click)="closeManifest()">
|
||||
<fa-icon icon="times"></fa-icon> Close
|
||||
</button>
|
||||
@if (manifestBatch?.documentId) {
|
||||
<button class="cv-btn cv-btn--primary" (click)="downloadZip(manifestBatch, $event)">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
<span>Download ZIP</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,297 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-113
@@ -6,120 +6,145 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<div class="layout-row responsive-column gap-20px">
|
||||
<div class="flex-50">
|
||||
<mat-nav-list>
|
||||
<mat-list-item>
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['record-master']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="database" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.Record Master' | translate }}
|
||||
@if (arrowBooleans[0]) {
|
||||
<p matLine [routerLink]="['record-master']" class="menu-explanation">
|
||||
{{ 'labels.text.Manage master records for CKYC processing' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[0]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[0]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(0)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<mat-list-item>
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['ckyc-verification']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="shield-alt" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.CKYC' | translate }}
|
||||
@if (arrowBooleans[1]) {
|
||||
<p matLine [routerLink]="['ckyc-verification']" class="menu-explanation">
|
||||
{{ 'labels.text.Central KYC verification and document management' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[1]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[1]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(1)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-nav-list>
|
||||
<div class="ckyc-dash">
|
||||
<!-- Hero -->
|
||||
<div class="ckyc-dash__hero">
|
||||
<div class="ckyc-dash__hero-left">
|
||||
<div class="ckyc-dash__hero-icon">
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
</div>
|
||||
|
||||
<div class="flex-50">
|
||||
<mat-nav-list>
|
||||
<mat-list-item>
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['ckyc-reports']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="chart-pie" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.CKYC Reports' | translate }}
|
||||
@if (arrowBooleans[2]) {
|
||||
<p matLine [routerLink]="['ckyc-reports']" class="menu-explanation">
|
||||
{{ 'labels.text.View and generate CKYC compliance reports' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[2]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[2]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(2)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
|
||||
<mat-list-item>
|
||||
<div class="menu-list-item-content">
|
||||
<div class="menu-left-section" [routerLink]="['ekyc']">
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="fingerprint" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<div matLine>
|
||||
{{ 'labels.heading.E-KYC' | translate }}
|
||||
@if (arrowBooleans[3]) {
|
||||
<p matLine [routerLink]="['ekyc']" class="menu-explanation">
|
||||
{{ 'labels.text.Electronic KYC verification using Aadhaar and biometrics' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-right-section">
|
||||
@if (!arrowBooleans[3]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-down" size="md"></fa-icon>
|
||||
}
|
||||
@if (arrowBooleans[3]) {
|
||||
<fa-icon (click)="arrowBooleansToggle(3)" icon="arrow-up" size="md"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-nav-list>
|
||||
<div>
|
||||
<h1 class="ckyc-dash__title">CKYC Compliance Centre</h1>
|
||||
<p class="ckyc-dash__sub">
|
||||
Central KYC registry management — collect, upload and verify client artefacts against CERSAI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
<div class="ckyc-dash__hero-badge">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>CERSAI ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5-Phase pipeline banner -->
|
||||
<div class="ckyc-pipeline">
|
||||
<div class="ckyc-pipeline__head">
|
||||
<span class="ckyc-pipeline__heading">
|
||||
<fa-icon icon="stream"></fa-icon> 5-phase compliance pipeline
|
||||
</span>
|
||||
<span class="ckyc-pipeline__hint">CBS → CKYC automation flow</span>
|
||||
</div>
|
||||
|
||||
<div class="ckyc-pipeline__track">
|
||||
<div class="ckyc-phase">
|
||||
<div class="ckyc-phase__num">1</div>
|
||||
<div class="ckyc-phase__icon"><fa-icon icon="database"></fa-icon></div>
|
||||
<div class="ckyc-phase__title">Mark Non-CKYC</div>
|
||||
<div class="ckyc-phase__desc">Identify missing CKYC registrations</div>
|
||||
</div>
|
||||
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
|
||||
|
||||
<div class="ckyc-phase">
|
||||
<div class="ckyc-phase__num">2</div>
|
||||
<div class="ckyc-phase__icon"><fa-icon icon="file-archive"></fa-icon></div>
|
||||
<div class="ckyc-phase__title">Auto ZIP</div>
|
||||
<div class="ckyc-phase__desc">Bundle into CERSAI format</div>
|
||||
</div>
|
||||
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
|
||||
|
||||
<div class="ckyc-phase ckyc-phase--accent ckyc-phase--wide">
|
||||
<div class="ckyc-phase__num ckyc-phase__num--dual">3 · 4</div>
|
||||
<div class="ckyc-phase__icon"><fa-icon icon="cloud-upload-alt"></fa-icon></div>
|
||||
<div class="ckyc-phase__title">SFTP Upload & API Match</div>
|
||||
<div class="ckyc-phase__desc">Transfer · ACK · Resolve CKYC numbers</div>
|
||||
</div>
|
||||
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
|
||||
|
||||
<div class="ckyc-phase">
|
||||
<div class="ckyc-phase__num">5</div>
|
||||
<div class="ckyc-phase__icon"><fa-icon icon="check-double"></fa-icon></div>
|
||||
<div class="ckyc-phase__title">Status Update</div>
|
||||
<div class="ckyc-phase__desc">Update master & MIS</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu cards -->
|
||||
<h2 class="ckyc-dash__section-title">
|
||||
<fa-icon icon="th-large"></fa-icon> Quick actions
|
||||
</h2>
|
||||
|
||||
<div class="ckyc-dash__grid">
|
||||
<a class="ckyc-card ckyc-card--blue" [routerLink]="['record-master']">
|
||||
<div class="ckyc-card__glow"></div>
|
||||
<div class="ckyc-card__icon">
|
||||
<fa-icon icon="database"></fa-icon>
|
||||
</div>
|
||||
<div class="ckyc-card__body">
|
||||
<div class="ckyc-card__title">Record Master</div>
|
||||
<p class="ckyc-card__desc">Manage master records, identify clients without CKYC and collect missing artefacts.</p>
|
||||
<div class="ckyc-card__tags">
|
||||
<span class="ckyc-tag">Phase 1</span>
|
||||
<span class="ckyc-tag">Clients</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-card__cta">
|
||||
Open <fa-icon icon="arrow-right"></fa-icon>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="ckyc-card ckyc-card--teal" [routerLink]="['ckyc-verification']">
|
||||
<div class="ckyc-card__glow"></div>
|
||||
<div class="ckyc-card__icon">
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
</div>
|
||||
<div class="ckyc-card__body">
|
||||
<div class="ckyc-card__title">CKYC Verification</div>
|
||||
<p class="ckyc-card__desc">Batch, upload and match records against the CERSAI registry.</p>
|
||||
<div class="ckyc-card__tags">
|
||||
<span class="ckyc-tag">Phase 2-4</span>
|
||||
<span class="ckyc-tag">Batches</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-card__cta">
|
||||
Open <fa-icon icon="arrow-right"></fa-icon>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="ckyc-card ckyc-card--amber" [routerLink]="['ckyc-reports']">
|
||||
<div class="ckyc-card__glow"></div>
|
||||
<div class="ckyc-card__icon">
|
||||
<fa-icon icon="chart-pie"></fa-icon>
|
||||
</div>
|
||||
<div class="ckyc-card__body">
|
||||
<div class="ckyc-card__title">CKYC Reports</div>
|
||||
<p class="ckyc-card__desc">Generate compliance MIS, track status counts and export for regulators.</p>
|
||||
<div class="ckyc-card__tags">
|
||||
<span class="ckyc-tag">Phase 5</span>
|
||||
<span class="ckyc-tag">MIS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-card__cta">
|
||||
Open <fa-icon icon="arrow-right"></fa-icon>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="ckyc-card ckyc-card--purple" [routerLink]="['ekyc']">
|
||||
<div class="ckyc-card__glow"></div>
|
||||
<div class="ckyc-card__icon">
|
||||
<fa-icon icon="fingerprint"></fa-icon>
|
||||
</div>
|
||||
<div class="ckyc-card__body">
|
||||
<div class="ckyc-card__title">E-KYC</div>
|
||||
<p class="ckyc-card__desc">Electronic KYC verification using Aadhaar OTP and biometric authentication.</p>
|
||||
<div class="ckyc-card__tags">
|
||||
<span class="ckyc-tag">Digital</span>
|
||||
<span class="ckyc-tag">Aadhaar</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ckyc-card__cta">
|
||||
Open <fa-icon icon="arrow-right"></fa-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -6,14 +6,943 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="placeholder-content">
|
||||
<fa-icon icon="database" size="3x" class="placeholder-icon"></fa-icon>
|
||||
<h3>{{ 'labels.heading.Record Master' | translate }}</h3>
|
||||
<p>{{ 'labels.text.This section is under development.' | translate }}</p>
|
||||
<div class="rm-container">
|
||||
<!-- Header -->
|
||||
<div class="rm-header">
|
||||
<div class="rm-header__left">
|
||||
<div class="rm-header__icon">
|
||||
<fa-icon icon="database"></fa-icon>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div>
|
||||
<h2 class="rm-header__title">{{ 'labels.heading.Record Master' | translate }}</h2>
|
||||
<p class="rm-header__sub">Clients missing CKYC registration or required artefacts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rm-header__actions">
|
||||
<button mat-stroked-button class="rm-btn rm-btn--ghost" (click)="fetchRecords()">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3-tab filter -->
|
||||
<div class="rm-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="rm-tab"
|
||||
[class.rm-tab--active]="activeTab === 'all'"
|
||||
(click)="setTab('all')"
|
||||
>
|
||||
<fa-icon icon="list"></fa-icon>
|
||||
<span>All clients</span>
|
||||
<span class="rm-tab__count">{{ tabCounts.all }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rm-tab rm-tab--amber"
|
||||
[class.rm-tab--active]="activeTab === 'pending-collection'"
|
||||
(click)="setTab('pending-collection')"
|
||||
>
|
||||
<fa-icon icon="hourglass-half"></fa-icon>
|
||||
<span>Pending collection</span>
|
||||
<span class="rm-tab__count">{{ tabCounts.pendingCollection }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rm-tab rm-tab--green"
|
||||
[class.rm-tab--active]="activeTab === 'ready'"
|
||||
(click)="setTab('ready')"
|
||||
>
|
||||
<fa-icon icon="check-double"></fa-icon>
|
||||
<span>Ready for CERSAI</span>
|
||||
<span class="rm-tab__count">{{ tabCounts.ready }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="rm-filters" [formGroup]="filterForm" (ngSubmit)="applyFilters()">
|
||||
<div class="rm-filters__grid">
|
||||
<div class="rm-field">
|
||||
<label class="rm-label"><fa-icon icon="building"></fa-icon> Office</label>
|
||||
<select class="rm-input" formControlName="officeId">
|
||||
<option value="">All offices</option>
|
||||
@for (o of offices; track o.id) {
|
||||
<option [value]="o.id">{{ o.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rm-field">
|
||||
<label class="rm-label"><fa-icon icon="cube"></fa-icon> Loan product ID</label>
|
||||
<input
|
||||
type="number"
|
||||
class="rm-input"
|
||||
formControlName="productId"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rm-field">
|
||||
<label class="rm-label"><fa-icon icon="calendar"></fa-icon> From date</label>
|
||||
<input type="date" class="rm-input" formControlName="fromDate" />
|
||||
</div>
|
||||
|
||||
<div class="rm-field">
|
||||
<label class="rm-label"><fa-icon icon="calendar"></fa-icon> To date</label>
|
||||
<input type="date" class="rm-input" formControlName="toDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rm-filters__actions">
|
||||
<button type="button" mat-stroked-button class="rm-btn rm-btn--ghost" (click)="resetFilters()">
|
||||
<fa-icon icon="times"></fa-icon> <span>Reset</span>
|
||||
</button>
|
||||
<button type="submit" mat-flat-button color="primary" class="rm-btn rm-btn--primary">
|
||||
<fa-icon icon="search"></fa-icon> <span>Apply filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading / error / empty -->
|
||||
@if (loading) {
|
||||
<div class="rm-state rm-state--loading">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Loading CKYC records…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error && !loading) {
|
||||
<div class="rm-state rm-state--error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading && !error && rows.length === 0) {
|
||||
<div class="rm-state rm-state--empty">
|
||||
<fa-icon icon="check-circle" size="3x"></fa-icon>
|
||||
<h3>No records found</h3>
|
||||
<p>All clients matching your filters have complete CKYC artefacts. 🎉</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results table -->
|
||||
@if (!loading && rows.length > 0) {
|
||||
<div class="rm-results">
|
||||
<div class="rm-results__head">
|
||||
<span class="rm-results__count">
|
||||
<fa-icon icon="list"></fa-icon>
|
||||
Showing <strong>{{ pageStart }}–{{ pageEnd }}</strong> of
|
||||
<strong>{{ totalFilteredRecords }}</strong>
|
||||
</span>
|
||||
@if (selected.size > 0) {
|
||||
<span class="rm-results__selected">
|
||||
<fa-icon icon="check-square"></fa-icon>
|
||||
<strong>{{ selected.size }}</strong> selected
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rm-table-wrap">
|
||||
<table class="rm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rm-table__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected"
|
||||
[indeterminate]="someSelected"
|
||||
(change)="toggleAll()"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th>Client</th>
|
||||
<th>Contact</th>
|
||||
<th>Address</th>
|
||||
<th class="rm-table__artefacts">Artefacts</th>
|
||||
<th class="rm-table__completion">Completion</th>
|
||||
<th class="rm-table__actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.clientId) {
|
||||
<tr
|
||||
class="rm-row"
|
||||
[class.rm-row--selected]="isSelected(row.clientId)"
|
||||
(click)="toggleRow(row.clientId, $event)"
|
||||
>
|
||||
<td class="rm-table__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(row.clientId)"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="toggleRow(row.clientId)"
|
||||
[attr.aria-label]="'Select ' + row.fullName"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="rm-row__client">
|
||||
<div class="rm-row__avatar">{{ row.fullName?.charAt(0) || '?' }}</div>
|
||||
<div class="rm-row__ident">
|
||||
<div class="rm-row__name">{{ row.fullName }}</div>
|
||||
<div class="rm-row__account">
|
||||
<fa-icon icon="hashtag"></fa-icon> {{ row.accountNo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="rm-row__line">
|
||||
<fa-icon icon="envelope"></fa-icon>
|
||||
<span [class.rm-row__missing]="!row.email">{{ row.email || 'No email' }}</span>
|
||||
</div>
|
||||
<div class="rm-row__line">
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
<span [class.rm-row__missing]="!row.mobile">{{ row.mobile || 'No mobile' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="rm-row__address">
|
||||
<fa-icon icon="map-marker-alt"></fa-icon>
|
||||
<span [class.rm-row__missing]="!row.address">{{ row.address || 'No address on file' }}</span>
|
||||
</td>
|
||||
<td class="rm-table__artefacts">
|
||||
<div class="rm-row__chips">
|
||||
<span class="rm-chip" [class.rm-chip--ok]="row.hasPhoto" [class.rm-chip--miss]="!row.hasPhoto" title="Photo">
|
||||
<fa-icon [icon]="row.hasPhoto ? 'check' : 'times'"></fa-icon> P
|
||||
</span>
|
||||
<span class="rm-chip" [class.rm-chip--ok]="row.hasSignature" [class.rm-chip--miss]="!row.hasSignature" title="Signature">
|
||||
<fa-icon [icon]="row.hasSignature ? 'check' : 'times'"></fa-icon> S
|
||||
</span>
|
||||
<span class="rm-chip" [class.rm-chip--ok]="row.hasIdProof" [class.rm-chip--miss]="!row.hasIdProof" title="ID Proof">
|
||||
<fa-icon [icon]="row.hasIdProof ? 'check' : 'times'"></fa-icon> ID
|
||||
</span>
|
||||
<span class="rm-chip" [class.rm-chip--ok]="row.hasAddressProof" [class.rm-chip--miss]="!row.hasAddressProof" title="Address Proof">
|
||||
<fa-icon [icon]="row.hasAddressProof ? 'check' : 'times'"></fa-icon> Ad
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="rm-table__completion">
|
||||
<div
|
||||
class="rm-completion"
|
||||
[class.rm-completion--full]="artefactCompletion(row) === 4"
|
||||
[class.rm-completion--half]="artefactCompletion(row) >= 2 && artefactCompletion(row) < 4"
|
||||
[class.rm-completion--low]="artefactCompletion(row) < 2"
|
||||
>
|
||||
{{ artefactCompletion(row) }}/4
|
||||
</div>
|
||||
</td>
|
||||
<td class="rm-table__actions">
|
||||
<button
|
||||
class="rm-action-btn"
|
||||
(click)="openView(row, $event)"
|
||||
title="View details"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="rm-action-btn rm-action-btn--primary"
|
||||
(click)="collectCkyc(row, $event)"
|
||||
title="Collect CKYC"
|
||||
>
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="rm-pager">
|
||||
<div class="rm-pager__size">
|
||||
<span>Rows per page:</span>
|
||||
<select class="rm-input rm-input--sm" [value]="limit" (change)="changeLimit($any($event.target).value)">
|
||||
<option [value]="10">10</option>
|
||||
<option [value]="25">25</option>
|
||||
<option [value]="50">50</option>
|
||||
<option [value]="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rm-pager__nav">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="rm-pager__btn"
|
||||
[disabled]="!canGoPrev"
|
||||
(click)="changePage(-1)"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<fa-icon icon="chevron-left"></fa-icon>
|
||||
</button>
|
||||
<span class="rm-pager__label">{{ pageStart }}–{{ pageEnd }}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="rm-pager__btn"
|
||||
[disabled]="!canGoNext"
|
||||
(click)="changePage(1)"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<fa-icon icon="chevron-right"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Floating action bar (when rows are selected) -->
|
||||
@if (selected.size > 0) {
|
||||
<div class="rm-action-bar">
|
||||
<div class="rm-action-bar__info">
|
||||
<div class="rm-action-bar__count">
|
||||
<fa-icon icon="check-square"></fa-icon>
|
||||
<strong>{{ selected.size }}</strong> selected
|
||||
</div>
|
||||
@if (selectedIncompleteCount > 0) {
|
||||
<span class="rm-action-bar__warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ selectedIncompleteCount }} incomplete
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="rm-action-bar__btns">
|
||||
<button class="rm-btn rm-btn--ghost-light" (click)="clearSelection()">
|
||||
<fa-icon icon="times"></fa-icon> <span>Clear</span>
|
||||
</button>
|
||||
<button class="rm-btn rm-btn--amber" (click)="openZipDialog()">
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
<span>Create Auto ZIP ({{ selected.size }})</span>
|
||||
<fa-icon icon="arrow-right"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Client details view dialog -->
|
||||
@if (showViewDialog && viewDetails) {
|
||||
<div class="rm-modal" (click)="closeView()">
|
||||
<div class="rm-modal__card rm-modal__card--wide" (click)="$event.stopPropagation()">
|
||||
<div class="rm-modal__header">
|
||||
<div class="rm-modal__title rm-modal__title--blue">
|
||||
<fa-icon icon="id-card-alt"></fa-icon>
|
||||
Client KYC details
|
||||
</div>
|
||||
<button class="rm-modal__close" (click)="closeView()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__body">
|
||||
<!-- Client header -->
|
||||
<div class="rm-view__client">
|
||||
<div class="rm-view__avatar">
|
||||
{{ viewDetails.row.fullName?.charAt(0) || '?' }}
|
||||
</div>
|
||||
<div class="rm-view__client-info">
|
||||
<div class="rm-view__client-name">{{ viewDetails.row.fullName }}</div>
|
||||
<div class="rm-view__client-meta">
|
||||
<span class="rm-view__cif">
|
||||
<fa-icon icon="hashtag"></fa-icon> CIF <strong>{{ viewDetails.row.accountNo }}</strong>
|
||||
</span>
|
||||
<span class="rm-view__sep">•</span>
|
||||
<span>
|
||||
<fa-icon icon="envelope"></fa-icon>
|
||||
{{ viewDetails.row.email || 'No email' }}
|
||||
</span>
|
||||
<span class="rm-view__sep">•</span>
|
||||
<span>
|
||||
<fa-icon icon="phone"></fa-icon>
|
||||
{{ viewDetails.row.mobile || 'No mobile' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (viewLoading) {
|
||||
<div class="rm-view__loading">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
Loading KYC details…
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC snapshot -->
|
||||
<div class="rm-view__section-title">
|
||||
<fa-icon icon="shield-alt"></fa-icon> KYC Snapshot
|
||||
</div>
|
||||
<div class="rm-view__kpis">
|
||||
<div class="rm-kpi">
|
||||
<div class="rm-kpi__label"><fa-icon icon="check-double"></fa-icon> KYC Status</div>
|
||||
@if (viewDetails.kycStatus) {
|
||||
<div
|
||||
class="rm-kpi__value rm-kpi__pill"
|
||||
[class.rm-kpi__pill--ok]="viewDetails.kycStatusCode === 'clientKycStatus.verified'"
|
||||
[class.rm-kpi__pill--warn]="viewDetails.kycStatusCode === 'clientKycStatus.pending' || viewDetails.kycStatusCode === 'clientKycStatus.initiated'"
|
||||
[class.rm-kpi__pill--err]="viewDetails.kycStatusCode === 'clientKycStatus.rejected' || viewDetails.kycStatusCode === 'clientKycStatus.expired'"
|
||||
>
|
||||
{{ viewDetails.kycStatus }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rm-kpi__value rm-kpi__missing">—</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rm-kpi">
|
||||
<div class="rm-kpi__label"><fa-icon icon="calendar-check"></fa-icon> KYC Date</div>
|
||||
<div class="rm-kpi__value">
|
||||
{{ viewDetails.kycDate || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rm-kpi">
|
||||
<div class="rm-kpi__label"><fa-icon icon="hourglass-half"></fa-icon> Expiry Date</div>
|
||||
<div class="rm-kpi__value">
|
||||
{{ viewDetails.expiryDate || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rm-kpi">
|
||||
<div class="rm-kpi__label"><fa-icon icon="exclamation-triangle"></fa-icon> Risk Category</div>
|
||||
<div class="rm-kpi__value">
|
||||
{{ viewDetails.riskCategory || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 documents -->
|
||||
<div class="rm-view__section-title">
|
||||
<fa-icon icon="folder-open"></fa-icon> CKYC Documents
|
||||
</div>
|
||||
<div class="rm-view__docs">
|
||||
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.photo, label: 'Photo / Image of Client', icon: 'user-circle' }"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.signature, label: 'Signature of Client', icon: 'file-signature' }"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.idProof, label: 'Identity Proof', icon: 'id-card' }"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="docTile; context: { slot: viewDetails.docs.addressProof, label: 'Address Proof', icon: 'home' }"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #docTile let-slot="slot" let-label="label" let-icon="icon">
|
||||
<div class="rm-doc" [class.rm-doc--ok]="slot.present" [class.rm-doc--miss]="!slot.present">
|
||||
<div class="rm-doc__head">
|
||||
<div class="rm-doc__icon"><fa-icon [icon]="icon"></fa-icon></div>
|
||||
<div class="rm-doc__body">
|
||||
<div class="rm-doc__label">{{ label }}</div>
|
||||
<div class="rm-doc__value">
|
||||
{{ slot.name }}
|
||||
@if (slot.key) {
|
||||
<span class="rm-doc__key">{{ slot.key }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<fa-icon
|
||||
class="rm-doc__state"
|
||||
[icon]="slot.present ? 'check-circle' : 'times-circle'"
|
||||
></fa-icon>
|
||||
</div>
|
||||
|
||||
@if (slot.files?.length) {
|
||||
<div class="rm-doc__files">
|
||||
@for (file of slot.files; track file.id) {
|
||||
<div class="rm-file" (click)="openFilePreview(file, $event)" title="Open">
|
||||
@if (file.previewUrl && file.isImage !== false && !file.isPdf) {
|
||||
<img [src]="file.previewUrl" class="rm-file__thumb" [alt]="file.name" />
|
||||
} @else if (file.isPdf) {
|
||||
<div class="rm-file__thumb rm-file__thumb--icon rm-file__thumb--pdf">
|
||||
<fa-icon icon="file-pdf"></fa-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rm-file__thumb rm-file__thumb--icon">
|
||||
<fa-icon icon="file-alt"></fa-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="rm-file__info">
|
||||
@if (file.fileName) {
|
||||
<span class="rm-file__name">{{ file.fileName }}</span>
|
||||
} @else {
|
||||
<span class="rm-file__name">{{ file.name }}</span>
|
||||
}
|
||||
<fa-icon icon="external-link-alt" class="rm-file__open"></fa-icon>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (slot.present) {
|
||||
<div class="rm-doc__placeholder">
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
Loading preview…
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__footer">
|
||||
<button class="rm-btn rm-btn--ghost" (click)="closeView()">
|
||||
<fa-icon icon="times"></fa-icon> Close
|
||||
</button>
|
||||
<button class="rm-btn rm-btn--primary" (click)="collectCkycFromView()">
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
<span>Collect CKYC</span>
|
||||
<fa-icon icon="arrow-right"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Auto ZIP Pipeline Dialog -->
|
||||
@if (showZipDialog) {
|
||||
<div class="rm-modal">
|
||||
<div class="rm-modal__card">
|
||||
<div class="rm-modal__header">
|
||||
<div class="rm-modal__title">
|
||||
<fa-icon icon="file-archive"></fa-icon>
|
||||
Auto ZIP Creation
|
||||
</div>
|
||||
<button class="rm-modal__close" [disabled]="zipRunning" (click)="closeZipDialog()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__body">
|
||||
<p class="rm-modal__intro">
|
||||
Bundling <strong>{{ selected.size }}</strong> client record{{ selected.size === 1 ? '' : 's' }}
|
||||
into CERSAI-format ZIP. Files will be packaged with an SHA-256 checksum and registered for SFTP upload.
|
||||
</p>
|
||||
|
||||
@if (selectedIncompleteCount > 0 && !zipRunning && !zipResult) {
|
||||
<div class="rm-modal__warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ selectedIncompleteCount }} record(s) are missing artefacts.</strong>
|
||||
They will still be packaged but flagged as incomplete in the manifest.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!zipRunning && !zipResult) {
|
||||
<div class="rm-field rm-field--remarks">
|
||||
<label class="rm-label">
|
||||
<fa-icon icon="sticky-note"></fa-icon> Remarks <span class="rm-label__opt">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="rm-input rm-textarea"
|
||||
rows="2"
|
||||
placeholder="e.g. Daily CERSAI push — 2026-04-14 15:30"
|
||||
[(ngModel)]="zipRemarks"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Stepper -->
|
||||
<ol class="rm-steps">
|
||||
@for (phase of zipPhases; track phase.key; let i = $index) {
|
||||
<li
|
||||
class="rm-step"
|
||||
[class.rm-step--active]="phase.status === 'active'"
|
||||
[class.rm-step--done]="phase.status === 'done'"
|
||||
[class.rm-step--failed]="phase.status === 'failed'"
|
||||
>
|
||||
<div class="rm-step__indicator">
|
||||
@if (phase.status === 'done') {
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
} @else if (phase.status === 'active') {
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
} @else if (phase.status === 'failed') {
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="rm-step__content">
|
||||
<div class="rm-step__label">
|
||||
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
|
||||
</div>
|
||||
@if (phase.message) {
|
||||
<div class="rm-step__msg">{{ phase.message }}</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (zipResult && !zipResult.error) {
|
||||
<div class="rm-modal__result">
|
||||
<div class="rm-modal__result-head">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>Batch created successfully</span>
|
||||
</div>
|
||||
<div class="rm-modal__result-grid">
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Batch ID</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ zipResult.batchId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Filename</span>
|
||||
<span class="rm-modal__result-value">{{ zipResult.filename }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Records</span>
|
||||
<span class="rm-modal__result-value">{{ zipResult.recordCount }}</span>
|
||||
</div>
|
||||
@if (zipResult.sizeBytes) {
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Size</span>
|
||||
<span class="rm-modal__result-value">{{ (zipResult.sizeBytes / 1024 / 1024).toFixed(2) }} MB</span>
|
||||
</div>
|
||||
}
|
||||
@if (zipResult.createdOn) {
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Created on</span>
|
||||
<span class="rm-modal__result-value">{{ zipResult.createdOn }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (zipResult.documentId) {
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Document ID</span>
|
||||
<span class="rm-modal__result-value">#{{ zipResult.documentId }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="rm-modal__result-grid-span">
|
||||
<span class="rm-modal__result-label">Checksum</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ zipResult.checksum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (zipResult.manifest && zipResult.manifest.length > 0) {
|
||||
<div class="rm-manifest">
|
||||
<button class="rm-manifest__toggle" (click)="toggleManifest()">
|
||||
<fa-icon [icon]="manifestExpanded ? 'chevron-down' : 'chevron-right'"></fa-icon>
|
||||
<span><strong>View manifest</strong> — {{ zipResult.manifest.length }} record(s) packaged</span>
|
||||
<span class="rm-manifest__stats">
|
||||
<span class="rm-manifest__stat rm-manifest__stat--full">
|
||||
<fa-icon icon="check-circle"></fa-icon> {{ manifestStats.full }} complete
|
||||
</span>
|
||||
@if (manifestStats.partial > 0) {
|
||||
<span class="rm-manifest__stat rm-manifest__stat--partial">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ manifestStats.partial }} partial
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (manifestExpanded) {
|
||||
<div class="rm-manifest__body">
|
||||
<table class="rm-manifest__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th class="rm-manifest__th-center">P</th>
|
||||
<th class="rm-manifest__th-center">S</th>
|
||||
<th class="rm-manifest__th-center">ID</th>
|
||||
<th class="rm-manifest__th-center">Ad</th>
|
||||
<th class="rm-manifest__th-right">Total size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of zipResult.manifest; track entry.clientId) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="rm-manifest__client">
|
||||
<strong>{{ entry.displayName || 'Client ' + entry.clientId }}</strong>
|
||||
@if (entry.cifNumber) {
|
||||
<span class="rm-manifest__cif">
|
||||
<fa-icon icon="hashtag"></fa-icon>{{ entry.cifNumber }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="rm-manifest__td-center">
|
||||
<span
|
||||
class="rm-manifest__chip"
|
||||
[class.rm-manifest__chip--ok]="entry.photo?.packaged"
|
||||
[class.rm-manifest__chip--miss]="!entry.photo?.packaged"
|
||||
[title]="entry.photo?.zipEntry || 'Missing'"
|
||||
>
|
||||
<fa-icon [icon]="entry.photo?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rm-manifest__td-center">
|
||||
<span
|
||||
class="rm-manifest__chip"
|
||||
[class.rm-manifest__chip--ok]="entry.signature?.packaged"
|
||||
[class.rm-manifest__chip--miss]="!entry.signature?.packaged"
|
||||
[title]="entry.signature?.zipEntry || 'Missing'"
|
||||
>
|
||||
<fa-icon [icon]="entry.signature?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rm-manifest__td-center">
|
||||
<span
|
||||
class="rm-manifest__chip"
|
||||
[class.rm-manifest__chip--ok]="entry.idProof?.packaged"
|
||||
[class.rm-manifest__chip--miss]="!entry.idProof?.packaged"
|
||||
[title]="(entry.idProof?.type || 'Identity Proof') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
|
||||
>
|
||||
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rm-manifest__td-center">
|
||||
<span
|
||||
class="rm-manifest__chip"
|
||||
[class.rm-manifest__chip--ok]="entry.addressProof?.packaged"
|
||||
[class.rm-manifest__chip--miss]="!entry.addressProof?.packaged"
|
||||
[title]="(entry.addressProof?.type || 'Address Proof') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
|
||||
>
|
||||
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rm-manifest__td-right rm-manifest__size">
|
||||
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (zipResult?.error) {
|
||||
<div class="rm-modal__error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Batch creation failed</strong>
|
||||
<span>{{ zipResult!.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__footer">
|
||||
<button class="rm-btn rm-btn--ghost" [disabled]="zipRunning" (click)="closeZipDialog()">
|
||||
@if (zipResult && !zipResult.error) { Close } @else { Cancel }
|
||||
</button>
|
||||
@if (!zipResult || zipResult.error) {
|
||||
<button
|
||||
class="rm-btn rm-btn--amber"
|
||||
[disabled]="zipRunning"
|
||||
(click)="startZipCreation()"
|
||||
>
|
||||
@if (zipRunning) {
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Creating…</span>
|
||||
} @else {
|
||||
<fa-icon icon="cogs"></fa-icon>
|
||||
<span>{{ zipResult?.error ? 'Retry' : 'Start ZIP creation' }}</span>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="rm-btn rm-btn--primary" (click)="proceedToUpload()">
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
<span>Proceed to SFTP upload</span>
|
||||
<fa-icon icon="arrow-right"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SFTP Upload Progress Dialog -->
|
||||
@if (showUploadDialog) {
|
||||
<div class="rm-modal">
|
||||
<div class="rm-modal__card">
|
||||
<div class="rm-modal__header">
|
||||
<div class="rm-modal__title rm-modal__title--teal">
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
SFTP Upload to CERSAI
|
||||
</div>
|
||||
<button class="rm-modal__close" [disabled]="uploadRunning" (click)="closeUploadDialog()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__body">
|
||||
<p class="rm-modal__intro">
|
||||
Transferring batch to the <strong>CERSAI SFTP</strong> drop server and waiting for
|
||||
acknowledgement. On success, the returned <strong>CKYC numbers</strong> will be saved
|
||||
against each client's master record.
|
||||
</p>
|
||||
|
||||
@if (uploadResult?.status === 'ALREADY_UPLOADED') {
|
||||
<div class="rm-modal__warn rm-modal__warn--blue">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>Batch already uploaded.</strong>
|
||||
{{ uploadResult?.remarks || 'No action was taken.' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Stepper -->
|
||||
<ol class="rm-steps">
|
||||
@for (phase of uploadPhases; track phase.key; let i = $index) {
|
||||
<li
|
||||
class="rm-step"
|
||||
[class.rm-step--active]="phase.status === 'active'"
|
||||
[class.rm-step--done]="phase.status === 'done'"
|
||||
[class.rm-step--failed]="phase.status === 'failed'"
|
||||
>
|
||||
<div class="rm-step__indicator">
|
||||
@if (phase.status === 'done') {
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
} @else if (phase.status === 'active') {
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
} @else if (phase.status === 'failed') {
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="rm-step__content">
|
||||
<div class="rm-step__label">
|
||||
<fa-icon [icon]="phase.icon"></fa-icon> {{ phase.label }}
|
||||
</div>
|
||||
@if (phase.message) {
|
||||
<div class="rm-step__msg">{{ phase.message }}</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (uploadResult && !uploadResult.error) {
|
||||
<div class="rm-modal__result">
|
||||
<div class="rm-modal__result-head">
|
||||
<fa-icon [icon]="uploadResult.status === 'ALREADY_UPLOADED' ? 'info-circle' : 'check-circle'"></fa-icon>
|
||||
<span>
|
||||
@if (uploadResult.status === 'ALREADY_UPLOADED') {
|
||||
Batch already uploaded to CERSAI
|
||||
} @else {
|
||||
Upload successful — ACK received
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rm-modal__result-grid">
|
||||
<div>
|
||||
<span class="rm-modal__result-label">CERSAI Reference</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.cersaiReference }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Batch ID</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.batchId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Uploaded on</span>
|
||||
<span class="rm-modal__result-value">{{ uploadResult.uploadedOn }}</span>
|
||||
</div>
|
||||
@if (uploadResult.receivedAt) {
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Received at</span>
|
||||
<span class="rm-modal__result-value">{{ uploadResult.receivedAt }}</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Records</span>
|
||||
<span class="rm-modal__result-value">
|
||||
{{ uploadResult.processedRecords }} / {{ uploadResult.totalRecords }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rm-modal__result-label">Status</span>
|
||||
<span class="rm-modal__result-value">
|
||||
<span
|
||||
class="rm-kpi__pill"
|
||||
[class.rm-kpi__pill--ok]="uploadResult.status === 'SUCCESS'"
|
||||
[class.rm-kpi__pill--warn]="uploadResult.status === 'ALREADY_UPLOADED'"
|
||||
>
|
||||
{{ uploadResult.status }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rm-modal__result-grid-span">
|
||||
<span class="rm-modal__result-label">Checksum sent</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumSent }}</span>
|
||||
</div>
|
||||
<div class="rm-modal__result-grid-span">
|
||||
<span class="rm-modal__result-label">Checksum verified</span>
|
||||
<span class="rm-modal__result-value rm-modal__result-value--mono">{{ uploadResult.checksumVerified }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (uploadResult.perClientCkycNumbers && uploadResult.perClientCkycNumbers.length > 0) {
|
||||
<div class="rm-ckyc-numbers">
|
||||
<div class="rm-ckyc-numbers__title">
|
||||
<fa-icon icon="id-card-alt"></fa-icon>
|
||||
Issued CKYC numbers ({{ uploadResult.perClientCkycNumbers.length }})
|
||||
</div>
|
||||
<div class="rm-ckyc-numbers__table-wrap">
|
||||
<table class="rm-ckyc-numbers__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>CIF</th>
|
||||
<th>CKYC Number</th>
|
||||
<th class="rm-ckyc-numbers__status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of uploadResult.perClientCkycNumbers; track c.clientId) {
|
||||
<tr>
|
||||
<td>#{{ c.clientId }}</td>
|
||||
<td class="rm-ckyc-numbers__mono">{{ c.cifNumber }}</td>
|
||||
<td class="rm-ckyc-numbers__mono rm-ckyc-numbers__ckyc">{{ c.ckycNumber }}</td>
|
||||
<td class="rm-ckyc-numbers__status">
|
||||
<span class="rm-kpi__pill rm-kpi__pill--ok">
|
||||
<fa-icon icon="check-circle"></fa-icon> {{ c.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (uploadResult?.error) {
|
||||
<div class="rm-modal__error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Upload failed</strong>
|
||||
<span>{{ uploadResult!.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rm-modal__footer">
|
||||
<button class="rm-btn rm-btn--ghost" [disabled]="uploadRunning" (click)="closeUploadDialog()">
|
||||
@if (uploadResult && !uploadResult.error) { Close } @else { Cancel }
|
||||
</button>
|
||||
@if (!uploadResult || uploadResult.error) {
|
||||
<button
|
||||
class="rm-btn rm-btn--amber"
|
||||
[disabled]="uploadRunning"
|
||||
(click)="startSftpUpload()"
|
||||
>
|
||||
@if (uploadRunning) {
|
||||
<fa-icon icon="spinner" animation="spin"></fa-icon>
|
||||
<span>Uploading…</span>
|
||||
} @else {
|
||||
<fa-icon icon="cloud-upload-alt"></fa-icon>
|
||||
<span>{{ uploadResult?.error ? 'Retry upload' : 'Start SFTP upload' }}</span>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="rm-btn rm-btn--primary" (click)="goToVerification()">
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
<span>Go to Verification</span>
|
||||
<fa-icon icon="arrow-right"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,891 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
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<number>();
|
||||
|
||||
/** Cached proof-type id → kind ('id' | 'address') map */
|
||||
private proofKindMap = new Map<string, 'id' | 'address'>();
|
||||
private proofKindLoaded = false;
|
||||
|
||||
/** View details dialog state */
|
||||
showViewDialog = false;
|
||||
viewLoading = false;
|
||||
viewDetails: ClientViewDetails | null = null;
|
||||
|
||||
/** Auto-ZIP pipeline dialog state */
|
||||
showZipDialog = false;
|
||||
zipRunning = false;
|
||||
zipPhases: ZipPhase[] = [
|
||||
{ key: 'validate', label: 'Validate records', icon: 'clipboard-check', status: 'pending' },
|
||||
{ key: 'collect', label: 'Collect artefacts', icon: 'folder-open', status: 'pending' },
|
||||
{ key: 'package', label: 'Package CERSAI ZIP', icon: 'file-archive', status: 'pending' },
|
||||
{ key: 'checksum', label: 'Generate checksum', icon: 'fingerprint', status: 'pending' },
|
||||
{ key: 'register', label: 'Register batch', icon: 'database', status: 'pending' }
|
||||
];
|
||||
zipResult: {
|
||||
id?: number;
|
||||
batchId?: string;
|
||||
filename?: string;
|
||||
checksum?: string;
|
||||
recordCount?: number;
|
||||
sizeBytes?: number;
|
||||
createdOn?: string;
|
||||
documentId?: number;
|
||||
uploadedToCersai?: boolean;
|
||||
manifest?: any[];
|
||||
error?: string;
|
||||
} | null = null;
|
||||
zipRemarks = '';
|
||||
manifestExpanded = false;
|
||||
|
||||
/** SFTP upload dialog state */
|
||||
showUploadDialog = false;
|
||||
uploadRunning = false;
|
||||
uploadPhases: ZipPhase[] = [
|
||||
{ key: 'connect', label: 'Connect to CERSAI SFTP', icon: 'link', status: 'pending' },
|
||||
{ key: 'transfer', label: 'Transfer ZIP file', icon: 'cloud-upload-alt', status: 'pending' },
|
||||
{ key: 'verify', label: 'Verify checksum', icon: 'fingerprint', status: 'pending' },
|
||||
{ key: 'ack', label: 'Await ACK receipt', icon: 'inbox', status: 'pending' },
|
||||
{ key: 'register', label: 'Update CKYC numbers', icon: 'id-card-alt', status: 'pending' }
|
||||
];
|
||||
uploadResult: {
|
||||
status?: string;
|
||||
batchRegistryId?: number;
|
||||
batchId?: string;
|
||||
filename?: string;
|
||||
cersaiReference?: string;
|
||||
receivedAt?: string;
|
||||
uploadedOn?: string;
|
||||
checksumSent?: string;
|
||||
checksumVerified?: string;
|
||||
totalRecords?: number;
|
||||
processedRecords?: number;
|
||||
perClientCkycNumbers?: any[];
|
||||
remarks?: string;
|
||||
error?: string;
|
||||
} | null = null;
|
||||
|
||||
toggleManifest(): void {
|
||||
this.manifestExpanded = !this.manifestExpanded;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number | undefined): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
get manifestStats() {
|
||||
if (!this.zipResult?.manifest) return { full: 0, partial: 0, total: 0 };
|
||||
const total = this.zipResult.manifest.length;
|
||||
let full = 0;
|
||||
this.zipResult.manifest.forEach((m: any) => {
|
||||
if (m.photo?.packaged && m.signature?.packaged && m.idProof?.packaged && m.addressProof?.packaged) {
|
||||
full++;
|
||||
}
|
||||
});
|
||||
return { full, partial: total - full, total };
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.filterForm = this.fb.group({
|
||||
officeId: [''],
|
||||
productId: [''],
|
||||
fromDate: [''],
|
||||
toDate: ['']
|
||||
});
|
||||
|
||||
this.loadOffices();
|
||||
this.loadProofKinds();
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround for backend bug where /ckyc/record-master returns
|
||||
* hasIdProof=false even when an active PAN/Aadhaar/etc identifier
|
||||
* exists. We load the proof-type map once and then per-row override
|
||||
* hasIdProof / hasAddressProof using /clients/{id}/identifiers.
|
||||
*
|
||||
* TODO: remove once backend SQL is fixed.
|
||||
*/
|
||||
private loadProofKinds(): void {
|
||||
this.clientsService
|
||||
.getKycProofTypes()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
(res?.idProofs || []).forEach((p: any) =>
|
||||
this.proofKindMap.set(String(p.id), 'id')
|
||||
);
|
||||
(res?.addressProofs || []).forEach((p: any) =>
|
||||
this.proofKindMap.set(String(p.id), 'address')
|
||||
);
|
||||
this.proofKindLoaded = true;
|
||||
// If records are already loaded, re-verify them now
|
||||
if (this.rows.length > 0) this.reverifyArtefacts();
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
private reverifyArtefacts(): void {
|
||||
if (!this.proofKindLoaded || this.allRows.length === 0) return;
|
||||
const requests: { [clientId: number]: any } = {};
|
||||
this.allRows.forEach((row) => {
|
||||
requests[row.clientId] = this.clientsService
|
||||
.getClientIdentifiers(String(row.clientId))
|
||||
.pipe(catchError(() => of([])));
|
||||
});
|
||||
forkJoin(requests)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((map: any) => {
|
||||
this.allRows.forEach((row) => {
|
||||
const list = Array.isArray(map[row.clientId])
|
||||
? map[row.clientId]
|
||||
: (map[row.clientId] as any)?.pageItems || [];
|
||||
let hasId = false;
|
||||
let hasAddr = false;
|
||||
for (const idf of list) {
|
||||
const docTypeId = String(idf?.documentType?.id ?? '');
|
||||
const kind = this.proofKindMap.get(docTypeId);
|
||||
const isActive =
|
||||
idf?.status === 'clientIdentifierStatusType.active' ||
|
||||
idf?.status === 'Active' ||
|
||||
!idf?.status;
|
||||
if (!isActive || !kind) continue;
|
||||
if (kind === 'id') hasId = true;
|
||||
else if (kind === 'address') hasAddr = true;
|
||||
}
|
||||
// Override backend values only when we find something it missed
|
||||
if (hasId) row.hasIdProof = true;
|
||||
if (hasAddr) row.hasAddressProof = true;
|
||||
});
|
||||
// Re-apply tab filter + counts now that overrides are in place
|
||||
this.applyTabFilter();
|
||||
});
|
||||
}
|
||||
|
||||
private loadOffices(): void {
|
||||
this.clientsService
|
||||
.getOffices()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.offices = Array.isArray(res) ? res : res?.pageItems || [];
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
fetchRecords(newOffset: number | null = null): void {
|
||||
if (newOffset !== null) this.offset = newOffset;
|
||||
const v = this.filterForm.value;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
// Only the "Pending Collection" tab passes missing=any; All and Ready tabs
|
||||
// omit the param so complete clients (like client 89) show up too.
|
||||
const missing = this.activeTab === 'pending-collection' ? 'any' : undefined;
|
||||
|
||||
this.clientsService
|
||||
.getCkycRecordMaster({
|
||||
officeId: v.officeId || undefined,
|
||||
productId: v.productId || undefined,
|
||||
fromDate: this.formatDate(v.fromDate),
|
||||
toDate: this.formatDate(v.toDate),
|
||||
missing,
|
||||
limit: this.limit,
|
||||
offset: this.offset
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.allRows = res?.pageItems || [];
|
||||
this.totalFilteredRecords = res?.totalFilteredRecords || 0;
|
||||
this.applyTabFilter();
|
||||
this.reverifyArtefacts();
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.loading = false;
|
||||
this.error = err?.error?.defaultUserMessage || 'Failed to load CKYC records.';
|
||||
this.allRows = [];
|
||||
this.rows = [];
|
||||
this.totalFilteredRecords = 0;
|
||||
this.tabCounts = { all: 0, pendingCollection: 0, ready: 0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply the tab's client-side filter and recompute counts */
|
||||
private applyTabFilter(): void {
|
||||
if (this.activeTab === 'ready') {
|
||||
this.rows = this.allRows.filter(
|
||||
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
|
||||
);
|
||||
} else {
|
||||
this.rows = this.allRows;
|
||||
}
|
||||
this.tabCounts = this.computeCounts(this.allRows);
|
||||
}
|
||||
|
||||
private computeCounts(all: CkycRecordRow[]) {
|
||||
return {
|
||||
all: all.length,
|
||||
pendingCollection: all.filter(
|
||||
(r) => !r.hasPhoto || !r.hasSignature || !r.hasIdProof || !r.hasAddressProof
|
||||
).length,
|
||||
ready: all.filter(
|
||||
(r) => r.hasPhoto && r.hasSignature && r.hasIdProof && r.hasAddressProof
|
||||
).length
|
||||
};
|
||||
}
|
||||
|
||||
setTab(tab: TabKey): void {
|
||||
if (this.activeTab === tab) return;
|
||||
this.activeTab = tab;
|
||||
this.offset = 0;
|
||||
this.selected.clear();
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
private formatDate(value: any): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.offset = 0;
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.filterForm.reset({ officeId: '', productId: '', fromDate: '', toDate: '' });
|
||||
this.offset = 0;
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
changePage(direction: -1 | 1): void {
|
||||
const next = this.offset + direction * this.limit;
|
||||
if (next < 0) return;
|
||||
this.offset = next;
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
changeLimit(newLimit: number): void {
|
||||
this.limit = Number(newLimit) || 25;
|
||||
this.offset = 0;
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
openClient(row: CkycRecordRow): void {
|
||||
this.router.navigate(['/clients/', row.clientId, 'general']);
|
||||
}
|
||||
|
||||
openView(row: CkycRecordRow, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.releaseViewBlobUrls();
|
||||
this.showViewDialog = true;
|
||||
this.viewLoading = true;
|
||||
this.viewDetails = {
|
||||
row,
|
||||
docs: {
|
||||
photo: { present: row.hasPhoto, name: row.hasPhoto ? 'Profile photo' : 'Not uploaded', files: [] },
|
||||
signature: { present: row.hasSignature, name: row.hasSignature ? 'Client signature' : 'Not uploaded', files: [] },
|
||||
idProof: { present: row.hasIdProof, name: row.hasIdProof ? 'Identifier' : 'Not uploaded', files: [] },
|
||||
addressProof: { present: row.hasAddressProof, name: row.hasAddressProof ? 'Identifier' : 'Not uploaded', files: [] }
|
||||
}
|
||||
};
|
||||
|
||||
forkJoin({
|
||||
kyc: this.clientsService.getClientKyc(String(row.clientId)).pipe(catchError(() => of(null))),
|
||||
identifiers: this.clientsService.getClientIdentifiers(String(row.clientId)).pipe(catchError(() => of([]))),
|
||||
documents: this.clientsService.getClientDocuments(String(row.clientId)).pipe(catchError(() => of([]))),
|
||||
photo: this.clientsService.getClientProfileImage(String(row.clientId)).pipe(catchError(() => of(null)))
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(({ kyc, identifiers, documents, photo }) => {
|
||||
if (!this.viewDetails) return;
|
||||
|
||||
// KYC status + dates
|
||||
if (kyc) {
|
||||
const code = kyc.kycStatusCode || '';
|
||||
this.viewDetails.kycStatus = this.formatKycStatus(code);
|
||||
this.viewDetails.kycStatusCode = code;
|
||||
this.viewDetails.kycDate = this.formatJavaDate(kyc.verifiedOn || kyc.initiatedOn);
|
||||
this.viewDetails.expiryDate = this.formatJavaDate(kyc.expiryDate);
|
||||
this.viewDetails.riskCategory = (kyc.riskCategoryCode || '').replace('kycRiskCategory.', '').toUpperCase();
|
||||
}
|
||||
|
||||
// 1. Photo — from /clients/{id}/images (base64 data URL)
|
||||
if (photo && typeof photo === 'string' && photo.length > 0) {
|
||||
this.viewDetails.docs.photo = {
|
||||
present: true,
|
||||
name: 'Profile photo',
|
||||
files: [
|
||||
{
|
||||
id: 'client-photo',
|
||||
name: 'Profile photo',
|
||||
previewUrl: photo,
|
||||
isImage: true,
|
||||
source: 'photo'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document named 'clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
if (signatureDoc) {
|
||||
this.viewDetails.docs.signature = {
|
||||
present: true,
|
||||
name: signatureDoc.fileName || 'Client signature',
|
||||
files: [
|
||||
{
|
||||
id: signatureDoc.id,
|
||||
name: signatureDoc.name,
|
||||
fileName: signatureDoc.fileName,
|
||||
type: signatureDoc.type,
|
||||
parentEntityId: row.clientId,
|
||||
source: 'client',
|
||||
isImage: /image/i.test(signatureDoc.type || ''),
|
||||
isPdf: /pdf/i.test(signatureDoc.type || '')
|
||||
}
|
||||
]
|
||||
};
|
||||
// Fetch blob for thumbnail
|
||||
this.clientsService
|
||||
.getClientSignatureImage(String(row.clientId), String(signatureDoc.id))
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob && this.viewDetails?.docs.signature.files[0]) {
|
||||
this.viewDetails.docs.signature.files[0].previewUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
// 3 & 4. Identifier ID/Address proof — classify and fetch attached documents
|
||||
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
|
||||
let updatedHasId = false;
|
||||
let updatedHasAddr = false;
|
||||
for (const idf of idList) {
|
||||
const docTypeId = String(idf?.documentType?.id ?? '');
|
||||
const kind = this.proofKindMap.get(docTypeId);
|
||||
const isActive =
|
||||
idf?.status === 'clientIdentifierStatusType.active' ||
|
||||
idf?.status === 'Active' ||
|
||||
!idf?.status;
|
||||
if (!isActive || !kind) continue;
|
||||
const proofName = idf?.documentType?.name || 'Identifier';
|
||||
const rawKey = idf?.documentKey || '';
|
||||
const synthetic = rawKey.startsWith(`${proofName} - `);
|
||||
const slot: DocSlot = {
|
||||
present: true,
|
||||
name: proofName,
|
||||
key: synthetic ? '' : rawKey,
|
||||
files: []
|
||||
};
|
||||
|
||||
if (kind === 'id' && !updatedHasId) {
|
||||
this.viewDetails.docs.idProof = slot;
|
||||
updatedHasId = true;
|
||||
this.loadIdentifierFiles(idf.id, 'idProof');
|
||||
} else if (kind === 'address' && !updatedHasAddr) {
|
||||
this.viewDetails.docs.addressProof = slot;
|
||||
updatedHasAddr = true;
|
||||
this.loadIdentifierFiles(idf.id, 'addressProof');
|
||||
}
|
||||
}
|
||||
|
||||
this.viewLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch documents attached to a client_identifier and load previews */
|
||||
private loadIdentifierFiles(identifierId: number | string, slot: 'idProof' | 'addressProof'): void {
|
||||
this.clientsService
|
||||
.getClientIdentificationDocuments(String(identifierId))
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (docs: any) => {
|
||||
const list = Array.isArray(docs) ? docs : (docs as any)?.pageItems || [];
|
||||
if (!this.viewDetails) return;
|
||||
const files: DocFile[] = list.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
fileName: d.fileName,
|
||||
type: d.type,
|
||||
parentEntityId: identifierId,
|
||||
source: 'identifier' as const,
|
||||
isImage: /image/i.test(d.type || ''),
|
||||
isPdf: /pdf/i.test(d.type || '')
|
||||
}));
|
||||
this.viewDetails.docs[slot].files = files;
|
||||
// Fetch blob previews
|
||||
files.forEach((f) => {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(String(identifierId), String(f.id))
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (blob: any) => {
|
||||
if (blob) f.previewUrl = URL.createObjectURL(blob);
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
private releaseViewBlobUrls(): void {
|
||||
if (!this.viewDetails) return;
|
||||
const all = [
|
||||
...this.viewDetails.docs.photo.files,
|
||||
...this.viewDetails.docs.signature.files,
|
||||
...this.viewDetails.docs.idProof.files,
|
||||
...this.viewDetails.docs.addressProof.files
|
||||
];
|
||||
all.forEach((f) => {
|
||||
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl);
|
||||
});
|
||||
}
|
||||
|
||||
openFilePreview(file: DocFile, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!file.previewUrl) return;
|
||||
window.open(file.previewUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
closeView(): void {
|
||||
this.releaseViewBlobUrls();
|
||||
this.showViewDialog = false;
|
||||
this.viewDetails = null;
|
||||
}
|
||||
|
||||
private formatJavaDate(value: any): string {
|
||||
if (!value) return '';
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d] = value;
|
||||
return `${String(d).padStart(2, '0')}-${String(m).padStart(2, '0')}-${y}`;
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
private formatKycStatus(code: string): string {
|
||||
if (!code) return 'Unknown';
|
||||
const map: Record<string, string> = {
|
||||
'clientKycStatus.verified': 'Verified',
|
||||
'clientKycStatus.initiated': 'Initiated',
|
||||
'clientKycStatus.pending': 'Pending',
|
||||
'clientKycStatus.rejected': 'Rejected',
|
||||
'clientKycStatus.expired': 'Expired'
|
||||
};
|
||||
return map[code] || code.replace('clientKycStatus.', '');
|
||||
}
|
||||
|
||||
collectCkycFromView(): void {
|
||||
if (!this.viewDetails) return;
|
||||
const row = this.viewDetails.row;
|
||||
this.closeView();
|
||||
this.collectCkyc(row, new Event('click'));
|
||||
}
|
||||
|
||||
collectCkyc(row: CkycRecordRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate(['/clients/', row.clientId, 'ckyc']);
|
||||
}
|
||||
|
||||
createClient(): void {
|
||||
this.router.navigate(['/clients/create']);
|
||||
}
|
||||
|
||||
artefactCompletion(row: CkycRecordRow): number {
|
||||
const items = [row.hasPhoto, row.hasSignature, row.hasIdProof, row.hasAddressProof];
|
||||
return items.filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** Selection helpers */
|
||||
toggleRow(clientId: number, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (this.selected.has(clientId)) this.selected.delete(clientId);
|
||||
else this.selected.add(clientId);
|
||||
}
|
||||
|
||||
isSelected(clientId: number): boolean {
|
||||
return this.selected.has(clientId);
|
||||
}
|
||||
|
||||
toggleAll(): void {
|
||||
if (this.allSelected) {
|
||||
this.rows.forEach((r) => this.selected.delete(r.clientId));
|
||||
} else {
|
||||
this.rows.forEach((r) => this.selected.add(r.clientId));
|
||||
}
|
||||
}
|
||||
|
||||
get allSelected(): boolean {
|
||||
return this.rows.length > 0 && this.rows.every((r) => this.selected.has(r.clientId));
|
||||
}
|
||||
|
||||
get someSelected(): boolean {
|
||||
return this.selected.size > 0 && !this.allSelected;
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selected.clear();
|
||||
}
|
||||
|
||||
get selectedRows(): CkycRecordRow[] {
|
||||
return this.rows.filter((r) => this.selected.has(r.clientId));
|
||||
}
|
||||
|
||||
get selectedCompleteCount(): number {
|
||||
return this.selectedRows.filter((r) => this.artefactCompletion(r) === 4).length;
|
||||
}
|
||||
|
||||
get selectedIncompleteCount(): number {
|
||||
return this.selectedRows.length - this.selectedCompleteCount;
|
||||
}
|
||||
|
||||
/** Auto-ZIP pipeline */
|
||||
openZipDialog(): void {
|
||||
if (this.selected.size === 0) return;
|
||||
this.showZipDialog = true;
|
||||
this.zipRunning = false;
|
||||
this.zipResult = null;
|
||||
this.manifestExpanded = false;
|
||||
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
|
||||
}
|
||||
|
||||
closeZipDialog(): void {
|
||||
if (this.zipRunning) return;
|
||||
this.showZipDialog = false;
|
||||
}
|
||||
|
||||
startZipCreation(): void {
|
||||
if (this.zipRunning || this.selected.size === 0) return;
|
||||
this.zipRunning = true;
|
||||
this.zipResult = null;
|
||||
// Reset phases
|
||||
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'pending' as const, message: undefined as string | undefined }));
|
||||
|
||||
// Kick off visual phase animation for steps 1-4 while the POST is in flight.
|
||||
// Step 5 (Register batch) is held until the API responds.
|
||||
this.animatePhases();
|
||||
|
||||
// Fire the real backend call
|
||||
const clientIds = Array.from(this.selected);
|
||||
const remarks = this.zipRemarks?.trim()
|
||||
? this.zipRemarks.trim()
|
||||
: `CERSAI push — ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
|
||||
|
||||
this.clientsService
|
||||
.createCkycBatch({ clientIds, remarks })
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
// Complete all remaining phases and populate result
|
||||
this.zipPhases = this.zipPhases.map((p) => ({ ...p, status: 'done' as const }));
|
||||
this.zipResult = {
|
||||
id: res?.id,
|
||||
batchId: res?.batchId,
|
||||
filename: res?.filename,
|
||||
checksum: res?.checksum,
|
||||
recordCount: res?.recordCount,
|
||||
sizeBytes: res?.sizeBytes,
|
||||
createdOn: res?.createdOn,
|
||||
documentId: res?.documentId,
|
||||
uploadedToCersai: res?.uploadedToCersai,
|
||||
manifest: res?.manifest || []
|
||||
};
|
||||
this.zipRunning = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
// Mark current active phase (or last pending) as failed
|
||||
const activeIdx = this.zipPhases.findIndex((p) => p.status === 'active');
|
||||
const idx = activeIdx >= 0 ? activeIdx : this.zipPhases.findIndex((p) => p.status === 'pending');
|
||||
if (idx >= 0) {
|
||||
this.zipPhases[idx].status = 'failed';
|
||||
this.zipPhases[idx].message =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'Request failed';
|
||||
}
|
||||
this.zipResult = {
|
||||
error: err?.error?.defaultUserMessage || err?.message || 'Failed to create CKYC batch.'
|
||||
};
|
||||
this.zipRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run phases 1-4 on a short timer as a visual progress indicator
|
||||
* while the single POST is in flight. Phase 5 is held — it only
|
||||
* completes when the API response arrives (success or failure).
|
||||
*/
|
||||
private animatePhases(): void {
|
||||
const step = 500; // ms per phase
|
||||
for (let i = 0; i < this.zipPhases.length - 1; i++) {
|
||||
setTimeout(() => {
|
||||
// Don't override a phase that's already done/failed by the API response
|
||||
if (this.zipPhases[i].status === 'pending') this.zipPhases[i].status = 'active';
|
||||
}, i * step);
|
||||
setTimeout(() => {
|
||||
if (this.zipPhases[i].status === 'active') this.zipPhases[i].status = 'done';
|
||||
}, (i + 1) * step);
|
||||
}
|
||||
// Activate phase 5 after phases 1-4 finish, leave it active until API returns
|
||||
setTimeout(() => {
|
||||
const last = this.zipPhases.length - 1;
|
||||
if (this.zipPhases[last].status === 'pending') this.zipPhases[last].status = 'active';
|
||||
}, (this.zipPhases.length - 1) * step);
|
||||
}
|
||||
|
||||
proceedToUpload(): void {
|
||||
if (!this.zipResult?.id) return;
|
||||
this.showZipDialog = false;
|
||||
this.openUploadDialog(this.zipResult.id);
|
||||
}
|
||||
|
||||
/** Open the SFTP upload progress dialog for a given batch registry id */
|
||||
openUploadDialog(batchRegistryId: number): void {
|
||||
this.showUploadDialog = true;
|
||||
this.uploadRunning = false;
|
||||
this.uploadResult = null;
|
||||
this.uploadPhases = this.uploadPhases.map((p) => ({
|
||||
...p,
|
||||
status: 'pending' as const,
|
||||
message: undefined as string | undefined
|
||||
}));
|
||||
// Stash the id for retry
|
||||
this.pendingUploadBatchId = batchRegistryId;
|
||||
}
|
||||
|
||||
private pendingUploadBatchId: number | null = null;
|
||||
|
||||
closeUploadDialog(): void {
|
||||
if (this.uploadRunning) return;
|
||||
this.showUploadDialog = false;
|
||||
this.pendingUploadBatchId = null;
|
||||
}
|
||||
|
||||
startSftpUpload(): void {
|
||||
if (this.uploadRunning || !this.pendingUploadBatchId) return;
|
||||
this.uploadRunning = true;
|
||||
this.uploadResult = null;
|
||||
this.uploadPhases = this.uploadPhases.map((p) => ({
|
||||
...p,
|
||||
status: 'pending' as const,
|
||||
message: undefined as string | undefined
|
||||
}));
|
||||
|
||||
// Animate phases 1-4 while POST is in flight; phase 5 held until response.
|
||||
this.animateUploadPhases();
|
||||
|
||||
this.clientsService
|
||||
.uploadCkycBatchToSftp(this.pendingUploadBatchId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.uploadPhases = this.uploadPhases.map((p) => ({ ...p, status: 'done' as const }));
|
||||
this.uploadResult = {
|
||||
status: res?.status,
|
||||
batchRegistryId: res?.batchRegistryId,
|
||||
batchId: res?.batchId,
|
||||
filename: res?.filename,
|
||||
cersaiReference: res?.cersaiReference,
|
||||
receivedAt: this.formatJavaDateTime(res?.receivedAt),
|
||||
uploadedOn: this.formatJavaDateTime(res?.uploadedOn),
|
||||
checksumSent: res?.checksumSent,
|
||||
checksumVerified: res?.checksumVerified,
|
||||
totalRecords: res?.totalRecords,
|
||||
processedRecords: res?.processedRecords,
|
||||
perClientCkycNumbers: res?.perClientCkycNumbers || [],
|
||||
remarks: res?.remarks
|
||||
};
|
||||
this.uploadRunning = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
const activeIdx = this.uploadPhases.findIndex((p) => p.status === 'active');
|
||||
const idx = activeIdx >= 0 ? activeIdx : this.uploadPhases.findIndex((p) => p.status === 'pending');
|
||||
if (idx >= 0) {
|
||||
this.uploadPhases[idx].status = 'failed';
|
||||
this.uploadPhases[idx].message =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || err?.message || 'Upload failed';
|
||||
}
|
||||
this.uploadResult = {
|
||||
error: err?.error?.defaultUserMessage || err?.message || 'SFTP upload failed.'
|
||||
};
|
||||
this.uploadRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private formatJavaDateTime(value: any): string {
|
||||
if (value == null || value === '') return '';
|
||||
const pad = (n: any) => String(n ?? 0).padStart(2, '0');
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, mo, d, h = 0, mi = 0, s = 0] = value;
|
||||
const date = `${pad(d)}-${pad(mo)}-${y}`;
|
||||
if (value.length >= 4) {
|
||||
return `${date} ${pad(h)}:${pad(mi)}:${pad(s)}`;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const dt = new Date(value);
|
||||
if (!isNaN(dt.getTime())) {
|
||||
return `${pad(dt.getDate())}-${pad(dt.getMonth() + 1)}-${dt.getFullYear()} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private animateUploadPhases(): void {
|
||||
const step = 550;
|
||||
for (let i = 0; i < this.uploadPhases.length - 1; i++) {
|
||||
setTimeout(() => {
|
||||
if (this.uploadPhases[i].status === 'pending') this.uploadPhases[i].status = 'active';
|
||||
}, i * step);
|
||||
setTimeout(() => {
|
||||
if (this.uploadPhases[i].status === 'active') this.uploadPhases[i].status = 'done';
|
||||
}, (i + 1) * step);
|
||||
}
|
||||
setTimeout(() => {
|
||||
const last = this.uploadPhases.length - 1;
|
||||
if (this.uploadPhases[last].status === 'pending') this.uploadPhases[last].status = 'active';
|
||||
}, (this.uploadPhases.length - 1) * step);
|
||||
}
|
||||
|
||||
goToVerification(): void {
|
||||
this.closeUploadDialog();
|
||||
this.router.navigate(['/ckyc/ckyc-verification']);
|
||||
}
|
||||
|
||||
get pageStart(): number {
|
||||
return this.totalFilteredRecords === 0 ? 0 : this.offset + 1;
|
||||
}
|
||||
|
||||
get pageEnd(): number {
|
||||
return this.offset + this.rows.length;
|
||||
}
|
||||
|
||||
get canGoPrev(): boolean {
|
||||
return this.offset > 0;
|
||||
}
|
||||
|
||||
get canGoNext(): boolean {
|
||||
return this.rows.length === this.limit;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user