216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
/**
|
|
* 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, 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);
|
|
data = inject<KycDocumentUpdateData>(MAT_DIALOG_DATA);
|
|
|
|
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() {
|
|
this.form = this.formBuilder.group({
|
|
documentNumber: [
|
|
this.data.documentNumber || '',
|
|
[Validators.required]
|
|
],
|
|
issueDate: [this.toDate(this.data.issueDate)],
|
|
// Left free rather than required: a passport expires, a PAN card does not.
|
|
expiryDate: [this.toDate(this.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: [
|
|
'',
|
|
this.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]!);
|
|
}
|
|
}
|
|
}
|
|
}
|