feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
+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