feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -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>
@@ -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;
}
@@ -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]!);
}
}
}
}