feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
@@ -82,12 +82,13 @@
|
||||
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
|
||||
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
|
||||
</div>
|
||||
<!-- Same dialog as the document's own row: the banner is asking for exactly that. -->
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kyc-doc-alert__action"
|
||||
(click)="toggleEditMode()"
|
||||
(click)="updateKycDocument()"
|
||||
>
|
||||
Update Document
|
||||
</button>
|
||||
@@ -470,7 +471,11 @@
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
|
||||
<!-- The document is re-collected in its own dialog, the way PAN and Aadhaar are dealt
|
||||
with on their rows, rather than by opening the whole KYC form for editing. -->
|
||||
<button mat-stroked-button class="kv-action-btn" type="button" (click)="updateKycDocument()">
|
||||
Update
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1188,15 +1193,83 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- The scans live with the document itself, on the identifier the customer's
|
||||
original was attached to. Re-scanning is done there rather than duplicated
|
||||
here, so both sides, the cropper and the replace-in-place all behave the
|
||||
same way they did when the document was first collected. -->
|
||||
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']">
|
||||
<mat-icon>photo_library</mat-icon>
|
||||
<span>Replace the scanned front / back of this document</span>
|
||||
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon>
|
||||
</a>
|
||||
<!-- The scan, where the document it belongs to is. Re-collecting an expiring
|
||||
document is what this panel is for, so sending the officer to the client
|
||||
edit screen to change the image made them leave the record they were
|
||||
working on and find the same document again. -->
|
||||
<div class="kyc-doc-scans">
|
||||
<div class="kyc-doc-scans__head">
|
||||
<mat-icon>photo_library</mat-icon>
|
||||
<span>Scanned document</span>
|
||||
@if (docScansLoading) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!docScansLoading && !docScans.front && !docScans.back) {
|
||||
<div class="kyc-doc-scans__empty">
|
||||
No scan is on file for this document. Upload one below.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="kyc-doc-scans__sides">
|
||||
@for (slot of docScanSlots; track slot) {
|
||||
<div class="kyc-doc-side">
|
||||
<div class="kyc-doc-side__label">{{ slot === 'front' ? 'Front' : 'Back' }}</div>
|
||||
|
||||
<div class="kyc-doc-side__frame">
|
||||
@if (docScanOf(slot)?.previewUrl) {
|
||||
<img
|
||||
[src]="docScanOf(slot)!.previewUrl"
|
||||
[alt]="slot === 'front' ? 'Front of document' : 'Back of document'"
|
||||
/>
|
||||
} @else if (docScanOf(slot)) {
|
||||
<!-- On file but not an image (a PDF, say) — named rather than shown. -->
|
||||
<div class="kyc-doc-side__file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span>{{ docScanOf(slot)!.fileName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kyc-doc-side__none">Not on file</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="kyc-doc-side__btn"
|
||||
[disabled]="!!docScanUploading || docScansLoading"
|
||||
(click)="scanInput.click()"
|
||||
>
|
||||
@if (docScanUploading === slot) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
Uploading…
|
||||
} @else {
|
||||
{{ docScanOf(slot) ? 'Replace' : 'Upload' }}
|
||||
}
|
||||
</button>
|
||||
<input
|
||||
#scanInput
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*,application/pdf"
|
||||
(change)="onDocScanSelected($event, slot)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (docScanError) {
|
||||
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--err">
|
||||
<mat-icon>error_outline</mat-icon><span>{{ docScanError }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (docScanSuccess) {
|
||||
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--ok">
|
||||
<mat-icon>check_circle</mat-icon><span>{{ docScanSuccess }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1681,3 +1681,129 @@
|
||||
.kyc-doc-scans-link__go {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* The scan itself, beside the number and dates it belongs to. */
|
||||
.kyc-doc-scans {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.kyc-doc-scans__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-scans__empty {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kyc-doc-scans__sides {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kyc-doc-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.kyc-doc-side__label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Fixed height so front and back line up whatever the scan's aspect ratio is. */
|
||||
.kyc-doc-side__frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 118px;
|
||||
padding: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-side__none {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.kyc-doc-side__file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
|
||||
mat-icon {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-side__btn {
|
||||
font-size: 12px;
|
||||
|
||||
mat-progress-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-scans__msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
&--err {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { catchError, of, Subject, takeUntil } from 'rxjs';
|
||||
import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
AadhaarReceiptDialogComponent,
|
||||
AadhaarReceiptResult
|
||||
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
|
||||
import {
|
||||
KycDocumentUpdateDialogComponent,
|
||||
KycDocumentUpdateData,
|
||||
KycDocumentUpdateResult
|
||||
} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** One side of the scanned KYC document, with the preview once it has been fetched. */
|
||||
interface DocScanSide {
|
||||
documentId: string;
|
||||
/** The identifier row this scan hangs off; the two sides can sit on different rows. */
|
||||
identifierId: string;
|
||||
fileName: string;
|
||||
previewUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* KYC Tab Component.
|
||||
* Displays and manages KYC (Know Your Customer) details for an existing client.
|
||||
@@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
exceptionReason: [[]]
|
||||
});
|
||||
|
||||
// ── Officially valid document scans ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The scans of the document named above, by side.
|
||||
*
|
||||
* The screen used to show the document's number and dates but not the image behind them, and
|
||||
* sent the officer to the client edit screen to change it. Re-collecting an expiring document is
|
||||
* the one thing this panel exists for, so the scan is shown and replaced here.
|
||||
*/
|
||||
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
|
||||
|
||||
/** The two sides, in display order, so the template loops rather than repeating itself. */
|
||||
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
|
||||
|
||||
docScanOf(slot: 'front' | 'back') {
|
||||
return this.docScans[slot];
|
||||
}
|
||||
|
||||
/** Identifier row replacements are filed against. Null when the document has no identifier. */
|
||||
private docIdentifierId: string | null = null;
|
||||
/** Every identifier row carrying this document, since the sides can be split across them. */
|
||||
private docIdentifierIds: string[] = [];
|
||||
docScansLoading = false;
|
||||
/** Which side is mid-upload, so only that button shows a spinner. */
|
||||
docScanUploading: 'front' | 'back' | null = null;
|
||||
docScanError: string | null = null;
|
||||
docScanSuccess: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.params.subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
// Scans are matched against the document number on the KYC record, so they are loaded once
|
||||
// that record has arrived rather than alongside it.
|
||||
this.loadKycData();
|
||||
this.loadClientDateOfBirth();
|
||||
});
|
||||
@@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
// Now that the document number is known, the scans filed under it can be found.
|
||||
this.loadDocumentScans();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1307,6 +1353,297 @@ I further confirm that:
|
||||
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
|
||||
}
|
||||
|
||||
// ── Document scans ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Finds the identifier the KYC document was filed under and loads its scans.
|
||||
*
|
||||
* The KYC record keeps the document's name and number but not the identifier id, so the two are
|
||||
* matched on the number — that is what makes them the same document, and it survives the client
|
||||
* holding several identifiers of the same type.
|
||||
*/
|
||||
private loadDocumentScans(): void {
|
||||
if (!this.clientId) {
|
||||
return;
|
||||
}
|
||||
this.docScansLoading = true;
|
||||
this.clientsService
|
||||
.getClientIdentifiers(this.clientId)
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((identifiers: any[]) => {
|
||||
const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
|
||||
const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
|
||||
const norm = (s: any) => (s || '').toString().trim().toLowerCase();
|
||||
|
||||
// Every identifier carrying this document number, not just the first. The onboarding step
|
||||
// files each side against its own identifier row, so this client's Aadhaar front sits on
|
||||
// one row and the back on another under the same number - taking only the first match
|
||||
// showed the front and reported the back as missing.
|
||||
let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
|
||||
if (!matches.length) {
|
||||
matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
|
||||
}
|
||||
|
||||
if (!matches.length) {
|
||||
this.docScansLoading = false;
|
||||
this.docIdentifierId = null;
|
||||
this.docIdentifierIds = [];
|
||||
this.docScans = { front: null, back: null };
|
||||
return;
|
||||
}
|
||||
// Replacements are filed against the first row, so the sides stay together from here on.
|
||||
this.docIdentifierId = String(matches[0].id);
|
||||
this.docIdentifierIds = matches.map((m: any) => String(m.id));
|
||||
this.fetchScansFor(this.docIdentifierIds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sides are read off the document name, which is how they were written at upload
|
||||
* ("<Proof> — Front" / "— Back"). Where the name says nothing, upload order stands in, front
|
||||
* first, rather than leaving a scan the officer cannot see.
|
||||
*/
|
||||
private fetchScansFor(identifierIds: string[]): void {
|
||||
forkJoin(
|
||||
identifierIds.map((id) =>
|
||||
this.clientsService.getClientIdentificationDocuments(id).pipe(
|
||||
// Tagged with the row it came from, because the download call needs the parent id and
|
||||
// the sides can be spread across more than one.
|
||||
map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
|
||||
catchError(() => of([] as any[]))
|
||||
)
|
||||
)
|
||||
).subscribe((perIdentifier: any[][]) => {
|
||||
this.docScansLoading = false;
|
||||
// This runs again after every replace, so the previews being thrown away are revoked
|
||||
// rather than left holding their blobs.
|
||||
for (const old of [this.docScans.front, this.docScans.back]) {
|
||||
if (old?.previewUrl) {
|
||||
URL.revokeObjectURL(old.previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Oldest first by document id, so the newest upload for a side is the last one written and
|
||||
// therefore the one that stands. Identifier order cannot do this job: a replacement is filed
|
||||
// against the first row while the original sits on the second, so reading the rows in order
|
||||
// let a document uploaded months ago overwrite the scan collected a moment ago - the officer
|
||||
// pressed Replace, saw "replaced", and the old image came straight back.
|
||||
const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
|
||||
const sides: typeof this.docScans = { front: null, back: null };
|
||||
const unlabelled: any[] = [];
|
||||
|
||||
const entryOf = (d: any): DocScanSide => ({
|
||||
documentId: String(d.id),
|
||||
identifierId: String(d.identifierId),
|
||||
fileName: d.fileName || d.name,
|
||||
previewUrl: null
|
||||
});
|
||||
|
||||
for (const d of docs) {
|
||||
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
|
||||
// Later uploads win, so a replaced side shows the new scan rather than the original.
|
||||
if (text.includes('back')) {
|
||||
sides.back = entryOf(d);
|
||||
} else if (text.includes('front')) {
|
||||
sides.front = entryOf(d);
|
||||
} else {
|
||||
unlabelled.push(d);
|
||||
}
|
||||
}
|
||||
for (const d of unlabelled) {
|
||||
if (!sides.front) {
|
||||
sides.front = entryOf(d);
|
||||
} else if (!sides.back) {
|
||||
sides.back = entryOf(d);
|
||||
}
|
||||
}
|
||||
|
||||
this.docScans = sides;
|
||||
for (const key of ['front', 'back'] as const) {
|
||||
if (sides[key]) {
|
||||
this.loadScanPreview(sides[key]!);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadScanPreview(side: DocScanSide): void {
|
||||
this.clientsService
|
||||
.downloadClientIdentificationDocument(side.identifierId, side.documentId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob || !blob.type?.startsWith('image/')) {
|
||||
return;
|
||||
}
|
||||
// Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
|
||||
// the tab.
|
||||
side.previewUrl = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one side with a freshly collected scan.
|
||||
*
|
||||
* The new file is uploaded under the same side name, which is what the readers here and on the
|
||||
* client screen key on, so the latest upload becomes the scan on show. The old file is left in
|
||||
* place deliberately: a KYC document that has been re-collected is a record of what was held
|
||||
* before and when it changed, and destroying it would leave the audit trail with a gap.
|
||||
*/
|
||||
onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// Cleared so choosing the same filename twice still fires a change event.
|
||||
input.value = '';
|
||||
if (!file || !this.docIdentifierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.docScanError = null;
|
||||
this.docScanSuccess = null;
|
||||
this.docScanUploading = slot;
|
||||
|
||||
const label = slot === 'front' ? 'Front' : 'Back';
|
||||
const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', `${docName} — ${label}`);
|
||||
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
|
||||
|
||||
this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
|
||||
next: () => {
|
||||
this.docScanUploading = null;
|
||||
this.docScanSuccess = `${label} side replaced.`;
|
||||
// Re-read every row, not just the one written to: the other side still lives on its own.
|
||||
this.fetchScansFor(this.docIdentifierIds);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.docScanUploading = null;
|
||||
this.docScanError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
|
||||
* one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
|
||||
*/
|
||||
private get isKycVerified(): boolean {
|
||||
return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-collects the officially valid document from its own row.
|
||||
*
|
||||
* PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
|
||||
* whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
|
||||
* the scan together, which is what re-collecting a document actually is.
|
||||
*/
|
||||
updateKycDocument(): void {
|
||||
if (!this.kycId) {
|
||||
this.docScanError = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialog
|
||||
.open(KycDocumentUpdateDialogComponent, {
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
documentName: this.kycData?.kycDocumentName || 'KYC document',
|
||||
documentNumber: this.kycData?.kycDocumentNumber || '',
|
||||
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
||||
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
||||
reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
|
||||
currentFront: this.docScans.front?.previewUrl || null,
|
||||
currentBack: this.docScans.back?.previewUrl || null,
|
||||
requiresRemarks: this.isKycVerified
|
||||
} as KycDocumentUpdateData
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: KycDocumentUpdateResult | undefined) => {
|
||||
if (result) {
|
||||
this.persistKycDocument(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the corrected details, then uploads whichever sides were re-scanned.
|
||||
*
|
||||
* The details go first: they are the record, and a scan attached to a document whose number was
|
||||
* never corrected would file the new image under the old identity.
|
||||
*/
|
||||
private persistKycDocument(result: KycDocumentUpdateResult): void {
|
||||
this.saving = true;
|
||||
this.docScanError = null;
|
||||
this.docScanSuccess = null;
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
kycDocumentName: this.kycData?.kycDocumentName || null,
|
||||
kycDocumentNumber: result.documentNumber || null,
|
||||
kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
|
||||
kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
|
||||
};
|
||||
// The server refuses an amendment to a verified record without one, and records it as the
|
||||
// reason the document changed.
|
||||
if (result.remarks) {
|
||||
payload.remarks = result.remarks;
|
||||
}
|
||||
|
||||
this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.docScanSuccess = 'Document updated.';
|
||||
this.loadKycData();
|
||||
this.uploadReplacedSides(result);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.docScanError =
|
||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends each re-scanned side through the same upload path the panel below uses. */
|
||||
private uploadReplacedSides(result: KycDocumentUpdateResult): void {
|
||||
if (!this.docIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const sides: { slot: 'front' | 'back'; file: File }[] = [];
|
||||
if (result.front) {
|
||||
sides.push({ slot: 'front', file: result.front });
|
||||
}
|
||||
if (result.back) {
|
||||
sides.push({ slot: 'back', file: result.back });
|
||||
}
|
||||
if (!sides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docName = this.kycData?.kycDocumentName || 'KYC document';
|
||||
forkJoin(
|
||||
sides.map(({ slot, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
|
||||
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
|
||||
return this.clientsService
|
||||
.uploadClientIdentifierDocument(this.docIdentifierId!, formData)
|
||||
.pipe(catchError(() => of(null)));
|
||||
})
|
||||
).subscribe(() => {
|
||||
this.docScanSuccess = 'Document and scan updated.';
|
||||
this.fetchScansFor(this.docIdentifierIds);
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle file selection for signed declaration upload */
|
||||
onDeclarationFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -1365,5 +1702,11 @@ I further confirm that:
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
|
||||
for (const side of [this.docScans.front, this.docScans.back]) {
|
||||
if (side?.previewUrl) {
|
||||
URL.revokeObjectURL(side.previewUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="kdu-title">
|
||||
<mat-icon>description</mat-icon>
|
||||
Update {{ data.documentName }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="kdu-body">
|
||||
<!-- Why this is being asked for, in the words the alert used. -->
|
||||
@if (data.reason) {
|
||||
<div class="kdu-reason">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span>{{ data.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" class="kdu-form">
|
||||
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
|
||||
<mat-label>Document Number</mat-label>
|
||||
<input matInput formControlName="documentNumber" autocomplete="off" />
|
||||
@if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) {
|
||||
<mat-error>Document number is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="kdu-field">
|
||||
<mat-label>Issued On</mat-label>
|
||||
<input matInput [matDatepicker]="issuePicker" formControlName="issueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="issuePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #issuePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="kdu-field">
|
||||
<mat-label>Valid Till</mat-label>
|
||||
<input matInput [matDatepicker]="expiryPicker" formControlName="expiryDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="expiryPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #expiryPicker></mat-datepicker>
|
||||
<mat-hint>Blank if it does not expire</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Only where the KYC record is already verified. Changing what a verified record holds is an
|
||||
amendment, and the reason goes on the record with it. -->
|
||||
@if (data.requiresRemarks) {
|
||||
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
|
||||
<mat-label>Reason for update</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
placeholder="e.g. Document expired — fresh copy collected from the customer"
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the verified KYC record</mat-hint>
|
||||
@if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) {
|
||||
<mat-error>A reason is required to change a verified KYC record</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
|
||||
<div class="kdu-scans">
|
||||
<div class="kdu-scans__head">Scanned copy</div>
|
||||
<p class="kdu-scans__hint">
|
||||
Leave a side untouched to keep the scan already on file. The previous copy is kept either way.
|
||||
</p>
|
||||
|
||||
<div class="kdu-sides">
|
||||
@for (slot of slots; track slot) {
|
||||
<div class="kdu-side">
|
||||
<div class="kdu-side__label">
|
||||
{{ slot === 'front' ? 'Front' : 'Back' }}
|
||||
@if (isReplaced(slot)) {
|
||||
<span class="kdu-side__new">New</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kdu-side__frame" [class.kdu-side__frame--new]="isReplaced(slot)">
|
||||
@if (previewOf(slot)) {
|
||||
<img [src]="previewOf(slot)" [alt]="slot === 'front' ? 'Front of document' : 'Back of document'" />
|
||||
} @else if (isReplaced(slot)) {
|
||||
<!-- Picked, but not an image (a PDF, say) — named rather than shown. -->
|
||||
<div class="kdu-side__file">
|
||||
<mat-icon>insert_drive_file</mat-icon>
|
||||
<span>{{ pickedName(slot) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="kdu-side__none">Not on file</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kdu-side__actions">
|
||||
<button type="button" mat-stroked-button class="kdu-side__btn" (click)="sideInput.click()">
|
||||
{{ previewOf(slot) || isReplaced(slot) ? 'Choose new scan' : 'Upload scan' }}
|
||||
</button>
|
||||
@if (isReplaced(slot)) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="kdu-side__undo"
|
||||
matTooltip="Keep the scan already on file"
|
||||
(click)="clearPick(slot)"
|
||||
>
|
||||
<mat-icon>undo</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
#sideInput
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*,application/pdf"
|
||||
(change)="onFilePicked($event, slot)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="kdu-actions">
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="form.invalid || !hasChanges"
|
||||
[matTooltip]="!hasChanges ? 'Change a detail or add a scan first' : ''"
|
||||
(click)="submit()"
|
||||
>
|
||||
Save Document
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.kdu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kdu-body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.kdu-reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.kdu-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kdu-field {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.kdu-field--wide {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.kdu-scans {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.kdu-scans__head {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.kdu-scans__hint {
|
||||
margin: 4px 0 12px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kdu-sides {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kdu-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.kdu-side__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Marks the side the officer has just re-scanned, so it is obvious what will change on save. */
|
||||
.kdu-side__new {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kdu-side__frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 132px;
|
||||
padding: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&--new {
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 0 0 1px #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
.kdu-side__none {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.kdu-side__file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
|
||||
mat-icon {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kdu-side__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kdu-side__btn {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kdu-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, Inject, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** What the caller hands in: the document as it stands, and the scans already on file. */
|
||||
export interface KycDocumentUpdateData {
|
||||
documentName: string;
|
||||
documentNumber: string;
|
||||
issueDate: Date | string | null;
|
||||
expiryDate: Date | string | null;
|
||||
/** Why the document is being asked for, shown at the top so the officer sees the reason. */
|
||||
reason: string;
|
||||
/** Previews of what is currently held, so the officer can see what they are replacing. */
|
||||
currentFront: string | null;
|
||||
currentBack: string | null;
|
||||
/**
|
||||
* True when the KYC record is already verified. Changing data on one of those needs a reason on
|
||||
* the record, which the server enforces - so the dialog asks for it rather than letting the
|
||||
* officer fill the whole form in and be refused on submit.
|
||||
*/
|
||||
requiresRemarks: boolean;
|
||||
}
|
||||
|
||||
/** What the caller gets back: the corrected details, plus whichever sides were re-scanned. */
|
||||
export interface KycDocumentUpdateResult {
|
||||
documentNumber: string;
|
||||
issueDate: Date | null;
|
||||
expiryDate: Date | null;
|
||||
front: File | null;
|
||||
back: File | null;
|
||||
/** Why the document was re-collected. Blank unless the record was already verified. */
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-collects an officially valid document without leaving the KYC record.
|
||||
*
|
||||
* PAN and Aadhaar are each dealt with on their own row of the verification table. The OVD was the
|
||||
* exception: its Update button put the whole KYC form into edit mode, so re-collecting one expiring
|
||||
* document meant scrolling a form of unrelated fields and finding the same document again. The work
|
||||
* is the same either way - a new number, new dates, a fresh scan of each side - so it is asked for
|
||||
* in one place.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-kyc-document-update-dialog',
|
||||
templateUrl: './kyc-document-update-dialog.component.html',
|
||||
styleUrls: ['./kyc-document-update-dialog.component.scss'],
|
||||
standalone: true,
|
||||
// The shared set carries the date adapter the two datepickers below need; the sibling dialogs
|
||||
// pull it in the same way.
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class KycDocumentUpdateDialogComponent implements OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dialogRef = inject<MatDialogRef<KycDocumentUpdateDialogComponent>>(MatDialogRef);
|
||||
|
||||
form: UntypedFormGroup;
|
||||
|
||||
/** Newly chosen files, with a local preview so the officer sees what they picked. */
|
||||
picked: { front: File | null; back: File | null } = { front: null, back: null };
|
||||
pickedPreview: { front: string | null; back: string | null } = { front: null, back: null };
|
||||
|
||||
readonly slots: ('front' | 'back')[] = ['front', 'back'];
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: KycDocumentUpdateData) {
|
||||
this.form = this.formBuilder.group({
|
||||
documentNumber: [
|
||||
data.documentNumber || '',
|
||||
[Validators.required]
|
||||
],
|
||||
issueDate: [this.toDate(data.issueDate)],
|
||||
// Left free rather than required: a passport expires, a PAN card does not.
|
||||
expiryDate: [this.toDate(data.expiryDate)],
|
||||
// Required only where the server requires it, so completing a record that was never verified
|
||||
// does not demand a justification for filling in blanks.
|
||||
remarks: [
|
||||
'',
|
||||
data.requiresRemarks ? [Validators.required] : []
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private toDate(value: Date | string | null): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
previewOf(slot: 'front' | 'back'): string | null {
|
||||
return this.pickedPreview[slot] || (slot === 'front' ? this.data.currentFront : this.data.currentBack);
|
||||
}
|
||||
|
||||
/** True once this side has been re-scanned in this dialog, so the label can say so. */
|
||||
isReplaced(slot: 'front' | 'back'): boolean {
|
||||
return !!this.picked[slot];
|
||||
}
|
||||
|
||||
pickedName(slot: 'front' | 'back'): string {
|
||||
return this.picked[slot]?.name || '';
|
||||
}
|
||||
|
||||
onFilePicked(event: Event, slot: 'front' | 'back'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] || null;
|
||||
// Cleared so re-picking the same filename still fires a change event.
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (this.pickedPreview[slot]) {
|
||||
URL.revokeObjectURL(this.pickedPreview[slot]!);
|
||||
}
|
||||
this.picked[slot] = file;
|
||||
this.pickedPreview[slot] = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
|
||||
}
|
||||
|
||||
clearPick(slot: 'front' | 'back'): void {
|
||||
if (this.pickedPreview[slot]) {
|
||||
URL.revokeObjectURL(this.pickedPreview[slot]!);
|
||||
}
|
||||
this.picked[slot] = null;
|
||||
this.pickedPreview[slot] = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something has to change for the update to mean anything - either a corrected detail or a fresh
|
||||
* scan. Submitting an untouched form would reopen a verified KYC record for no reason.
|
||||
*/
|
||||
get hasChanges(): boolean {
|
||||
if (this.picked.front || this.picked.back) {
|
||||
return true;
|
||||
}
|
||||
const v = this.form.value;
|
||||
const sameNumber = (v.documentNumber || '').trim() === (this.data.documentNumber || '').trim();
|
||||
const sameIssue = this.sameDay(v.issueDate, this.toDate(this.data.issueDate));
|
||||
const sameExpiry = this.sameDay(v.expiryDate, this.toDate(this.data.expiryDate));
|
||||
return !(sameNumber && sameIssue && sameExpiry);
|
||||
}
|
||||
|
||||
private sameDay(a: Date | null, b: Date | null): boolean {
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.hasChanges) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
const v = this.form.value;
|
||||
const result: KycDocumentUpdateResult = {
|
||||
documentNumber: (v.documentNumber || '').trim(),
|
||||
issueDate: v.issueDate || null,
|
||||
expiryDate: v.expiryDate || null,
|
||||
front: this.picked.front,
|
||||
back: this.picked.back,
|
||||
remarks: (v.remarks || '').trim()
|
||||
};
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Only the previews this dialog created; the caller owns the ones it passed in.
|
||||
for (const slot of this.slots) {
|
||||
if (this.pickedPreview[slot]) {
|
||||
URL.revokeObjectURL(this.pickedPreview[slot]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user