Files
cbsnexora/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts
T
nitesh ee5a430b17
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
# Conflicts:
#	src/environments/.env.ts
2026-09-14 12:04:24 +05:30

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]!);
}
}
}
}