/** * 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); data = inject(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]!); } } } }