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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-16 13:51:11 +05:30
co-authored by Claude Opus 4.6
parent 79e455c4ed
commit d72e969dc3
210 changed files with 14273 additions and 1139 deletions
@@ -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 &amp; ACK · API Match</span>
@if (totalPending > 0) {
<span class="cv-tab__count">{{ totalPending }}</span>
}
<span class="cv-tab__badge cv-tab__badge--amber">Phase 3-4</span>
</button>
</div>
<!-- SECTION 1: ZIP Details -->
@if (activeSection === 'zip') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="file-archive"></fa-icon>
<div>
<h3>ZIP Batch Details</h3>
<p>CERSAI ZIP archives grouped by creation date</p>
</div>
</div>
<span class="cv-badge cv-badge--blue">Phase 2</span>
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading batches…</span>
</div>
}
@if (error && !loading) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (!loading && !error && groups.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="file-archive" size="2x"></fa-icon>
<h4>No batches found</h4>
<p>Create a batch from the Record Master to see it here.</p>
</div>
}
@if (!loading && groups.length > 0) {
<div class="cv-groups">
@for (group of groups; track group.dateLabel) {
<div class="cv-group" [class.cv-group--open]="group.expanded">
<button class="cv-group__head" (click)="toggleGroup(group)">
<div class="cv-group__left">
<fa-icon [icon]="group.expanded ? 'chevron-down' : 'chevron-right'" class="cv-group__chev"></fa-icon>
<div class="cv-group__date-pill">
<fa-icon icon="calendar-check"></fa-icon>
<span>{{ group.dateLabel }}</span>
</div>
<div class="cv-group__summary">
<span><strong>{{ group.batchCount }}</strong> batches</span>
<span class="cv-group__sep">·</span>
<span><strong>{{ group.totalRecordCount }}</strong> records</span>
<span class="cv-group__sep">·</span>
<span>{{ formatBytes(group.totalSizeBytes) }}</span>
</div>
</div>
<div class="cv-group__right">
@if (group.pendingCount > 0) {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> {{ group.pendingCount }} pending
</span>
}
@if (group.uploadedCount > 0) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-double"></fa-icon> {{ group.uploadedCount }} uploaded
</span>
}
</div>
</button>
@if (group.expanded) {
<div class="cv-group__body">
<table class="cv-batches">
<thead>
<tr>
<th>Batch ID</th>
<th>Filename</th>
<th class="cv-batches__num">Records</th>
<th class="cv-batches__num">Size</th>
<th>Status</th>
<th class="cv-batches__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (batch of group.batches; track trackBatch($index, batch)) {
<tr>
<td class="cv-batches__bid">
<fa-icon icon="tag"></fa-icon>
<span>{{ batch.batchId }}</span>
</td>
<td class="cv-batches__file">
<fa-icon icon="file-archive"></fa-icon>
<span>{{ batch.filename }}</span>
</td>
<td class="cv-batches__num">{{ batch.recordCount }}</td>
<td class="cv-batches__num">{{ formatBytes(batch.sizeBytes) }}</td>
<td>
@if (batch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</td>
<td class="cv-batches__actions">
<button
class="cv-action-btn"
(click)="openManifest(batch, $event)"
title="View manifest"
>
<fa-icon icon="eye"></fa-icon>
</button>
@if (batch.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(batch, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- SECTION 2: SFTP Upload + API Match -->
@if (activeSection === 'sftp') {
<div class="cv-section">
<div class="cv-section__head">
<div class="cv-section__title">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<div>
<h3>SFTP Upload &amp; ACK · API Match</h3>
<p>CERSAI SFTP transfer history with ACK receipts and per-batch logs</p>
</div>
</div>
<span class="cv-badge cv-badge--amber">Phase 3-4</span>
</div>
<div class="cv-sftp-kpis">
<div class="cv-sftp-kpi cv-sftp-kpi--green">
<div class="cv-sftp-kpi__icon"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpSuccessCount }}</div>
<div class="cv-sftp-kpi__label">Uploaded successfully</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--red">
<div class="cv-sftp-kpi__icon"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpFailedCount }}</div>
<div class="cv-sftp-kpi__label">Failed</div>
</div>
</div>
<div class="cv-sftp-kpi cv-sftp-kpi--amber">
<div class="cv-sftp-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="cv-sftp-kpi__value">{{ sftpPendingCount }}</div>
<div class="cv-sftp-kpi__label">Pending upload</div>
</div>
</div>
</div>
@if (loading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading logs…</span>
</div>
}
@if (!loading && sftpLogs.length === 0) {
<div class="cv-state cv-state--empty">
<fa-icon icon="cloud-upload-alt" size="2x"></fa-icon>
<h4>No SFTP upload history yet</h4>
<p>Upload a batch from the Record Master to see CERSAI transfer logs here.</p>
</div>
}
@if (!loading && sftpLogs.length > 0) {
<div class="cv-sftp-logs">
@for (log of sftpLogs; track log.id) {
<div
class="cv-sftp-log"
[class.cv-sftp-log--success]="isSftpSuccess(log)"
[class.cv-sftp-log--failed]="isSftpFailed(log)"
>
<div class="cv-sftp-log__icon">
@if (isSftpSuccess(log)) {
<fa-icon icon="check-circle"></fa-icon>
} @else if (isSftpFailed(log)) {
<fa-icon icon="times-circle"></fa-icon>
} @else {
<fa-icon icon="hourglass-half"></fa-icon>
}
</div>
<div class="cv-sftp-log__body">
<div class="cv-sftp-log__head">
<span class="cv-sftp-log__bid">
<fa-icon icon="tag"></fa-icon> {{ log.batchId }}
</span>
@if (isSftpSuccess(log)) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Upload successful
</span>
} @else if (isSftpFailed(log)) {
<span class="cv-pill cv-pill--red">
<fa-icon icon="times-circle"></fa-icon> Upload failed
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
<span class="cv-sftp-log__time">
<fa-icon icon="clock"></fa-icon>
{{ log.uploadedOnLabel || log.createdOnLabel || '—' }}
</span>
</div>
<div class="cv-sftp-log__meta">
<div>
<span class="cv-sftp-log__label">Filename</span>
<span class="cv-sftp-log__value">{{ log.filename }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Records</span>
<span class="cv-sftp-log__value">{{ log.recordCount }}</span>
</div>
<div>
<span class="cv-sftp-log__label">Size</span>
<span class="cv-sftp-log__value">{{ formatBytes(log.sizeBytes) }}</span>
</div>
@if (log.cersaiReference) {
<div>
<span class="cv-sftp-log__label">CERSAI Ref</span>
<span class="cv-sftp-log__value cv-sftp-log__value--mono">{{ log.cersaiReference }}</span>
</div>
}
</div>
@if (log.uploadRemarks) {
<div class="cv-sftp-log__remarks" [class.cv-sftp-log__remarks--err]="isSftpFailed(log)">
<fa-icon [icon]="isSftpFailed(log) ? 'exclamation-triangle' : 'info-circle'"></fa-icon>
<span>{{ log.uploadRemarks }}</span>
</div>
}
</div>
<div class="cv-sftp-log__actions">
<button class="cv-action-btn" (click)="openManifest(log, $event)" title="View manifest">
<fa-icon icon="eye"></fa-icon>
</button>
@if (log.documentId) {
<button
class="cv-action-btn cv-action-btn--primary"
(click)="downloadZip(log, $event)"
title="Download ZIP"
>
<fa-icon icon="download"></fa-icon>
</button>
}
</div>
</div>
}
</div>
}
</div>
}
<!-- Manifest modal -->
@if (showManifest) {
<div class="cv-modal" (click)="closeManifest()">
<div class="cv-modal__card" (click)="$event.stopPropagation()">
<div class="cv-modal__header">
<div class="cv-modal__title">
<fa-icon icon="file-archive"></fa-icon>
Batch Manifest
</div>
<button class="cv-modal__close" (click)="closeManifest()">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="cv-modal__body">
@if (manifestLoading) {
<div class="cv-state cv-state--loading">
<fa-icon icon="spinner" animation="spin"></fa-icon>
<span>Loading manifest…</span>
</div>
}
@if (manifestError) {
<div class="cv-state cv-state--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ manifestError }}</span>
</div>
}
@if (manifestBatch && !manifestLoading) {
<div class="cv-manifest-head">
<div class="cv-manifest-head__grid">
<div>
<span class="cv-kpi__label">Batch ID</span>
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.batchId }}</span>
</div>
<div>
<span class="cv-kpi__label">Created on</span>
<span class="cv-kpi__value">{{ manifestBatch.createdOnLabel || '—' }}</span>
</div>
<div>
<span class="cv-kpi__label">Records</span>
<span class="cv-kpi__value">{{ manifestBatch.recordCount }}</span>
</div>
<div>
<span class="cv-kpi__label">Size</span>
<span class="cv-kpi__value">{{ formatBytes(manifestBatch.sizeBytes) }}</span>
</div>
<div>
<span class="cv-kpi__label">Status</span>
@if (manifestBatch.uploadedToCersai) {
<span class="cv-pill cv-pill--green">
<fa-icon icon="check-circle"></fa-icon> Uploaded
</span>
} @else {
<span class="cv-pill cv-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending
</span>
}
</div>
<div class="cv-manifest-head__grid-span">
<span class="cv-kpi__label">Filename</span>
<span class="cv-kpi__value">{{ manifestBatch.filename }}</span>
</div>
<div class="cv-manifest-head__grid-span">
<span class="cv-kpi__label">Checksum</span>
<span class="cv-kpi__value cv-kpi__value--mono">{{ manifestBatch.checksum }}</span>
</div>
</div>
</div>
@if (manifestBatch.manifest?.length > 0) {
<div class="cv-manifest-title">
<fa-icon icon="list-alt"></fa-icon>
Packaged clients ({{ manifestBatch.manifest.length }})
</div>
<div class="cv-manifest-table-wrap">
<table class="cv-manifest-table">
<thead>
<tr>
<th>Client</th>
<th class="cv-th-c">P</th>
<th class="cv-th-c">S</th>
<th class="cv-th-c">ID</th>
<th class="cv-th-c">Ad</th>
<th class="cv-th-r">Total size</th>
</tr>
</thead>
<tbody>
@for (entry of manifestBatch.manifest; track entry.clientId) {
<tr [class.cv-manifest-row--complete]="isEntryComplete(entry)">
<td>
<div class="cv-manifest-client">
<strong>{{ entry.displayName || 'Client ' + entry.clientId }}</strong>
@if (entry.cifNumber) {
<span class="cv-manifest-cif">
<fa-icon icon="hashtag"></fa-icon>{{ entry.cifNumber }}
</span>
}
</div>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.photo?.packaged"
[class.cv-dot--miss]="!entry.photo?.packaged"
[title]="entry.photo?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.photo?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.signature?.packaged"
[class.cv-dot--miss]="!entry.signature?.packaged"
[title]="entry.signature?.zipEntry || 'Missing'"
>
<fa-icon [icon]="entry.signature?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.idProof?.packaged"
[class.cv-dot--miss]="!entry.idProof?.packaged"
[title]="(entry.idProof?.type || 'ID') + (entry.idProof?.zipEntry ? ' — ' + entry.idProof.zipEntry : '')"
>
<fa-icon [icon]="entry.idProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-c">
<span
class="cv-dot"
[class.cv-dot--ok]="entry.addressProof?.packaged"
[class.cv-dot--miss]="!entry.addressProof?.packaged"
[title]="(entry.addressProof?.type || 'Address') + (entry.addressProof?.zipEntry ? ' — ' + entry.addressProof.zipEntry : '')"
>
<fa-icon [icon]="entry.addressProof?.packaged ? 'check' : 'times'"></fa-icon>
</span>
</td>
<td class="cv-td-r cv-manifest-size">
{{ formatBytes((entry.photo?.bytes || 0) + (entry.signature?.bytes || 0) + (entry.idProof?.bytes || 0) + (entry.addressProof?.bytes || 0)) }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
<div class="cv-modal__footer">
<button class="cv-btn cv-btn--ghost" (click)="closeManifest()">
<fa-icon icon="times"></fa-icon> Close
</button>
@if (manifestBatch?.documentId) {
<button class="cv-btn cv-btn--primary" (click)="downloadZip(manifestBatch, $event)">
<fa-icon icon="download"></fa-icon>
<span>Download ZIP</span>
</button>
}
</div>
</div>
</div>
}
</div>
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
View File
@@ -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&nbsp;·&nbsp;4</div>
<div class="ckyc-phase__icon"><fa-icon icon="cloud-upload-alt"></fa-icon></div>
<div class="ckyc-phase__title">SFTP Upload &amp; API Match</div>
<div class="ckyc-phase__desc">Transfer · ACK · Resolve CKYC numbers</div>
</div>
<div class="ckyc-phase__arrow"><fa-icon icon="long-arrow-alt-right"></fa-icon></div>
<div class="ckyc-phase">
<div class="ckyc-phase__num">5</div>
<div class="ckyc-phase__icon"><fa-icon icon="check-double"></fa-icon></div>
<div class="ckyc-phase__title">Status Update</div>
<div class="ckyc-phase__desc">Update master & MIS</div>
</div>
</div>
</div>
<!-- Menu cards -->
<h2 class="ckyc-dash__section-title">
<fa-icon icon="th-large"></fa-icon> Quick actions
</h2>
<div class="ckyc-dash__grid">
<a class="ckyc-card ckyc-card--blue" [routerLink]="['record-master']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="database"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">Record Master</div>
<p class="ckyc-card__desc">Manage master records, identify clients without CKYC and collect missing artefacts.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 1</span>
<span class="ckyc-tag">Clients</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--teal" [routerLink]="['ckyc-verification']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="shield-alt"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">CKYC Verification</div>
<p class="ckyc-card__desc">Batch, upload and match records against the CERSAI registry.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 2-4</span>
<span class="ckyc-tag">Batches</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--amber" [routerLink]="['ckyc-reports']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="chart-pie"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">CKYC Reports</div>
<p class="ckyc-card__desc">Generate compliance MIS, track status counts and export for regulators.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Phase 5</span>
<span class="ckyc-tag">MIS</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
<a class="ckyc-card ckyc-card--purple" [routerLink]="['ekyc']">
<div class="ckyc-card__glow"></div>
<div class="ckyc-card__icon">
<fa-icon icon="fingerprint"></fa-icon>
</div>
<div class="ckyc-card__body">
<div class="ckyc-card__title">E-KYC</div>
<p class="ckyc-card__desc">Electronic KYC verification using Aadhaar OTP and biometric authentication.</p>
<div class="ckyc-card__tags">
<span class="ckyc-tag">Digital</span>
<span class="ckyc-tag">Aadhaar</span>
</div>
</div>
<div class="ckyc-card__cta">
Open <fa-icon icon="arrow-right"></fa-icon>
</div>
</a>
</div>
</div>
+462
View File
@@ -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; }
}
+2 -16
View File
@@ -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;
}
}