Files
cbsnexora/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts
T

226 lines
6.7 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, OnInit, inject } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
/**
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
*
* A single alert is a transaction question and is handled on the Flagged Transactions tab. This tab
* asks a different question — is this *customer's* pattern of behaviour acceptable — which no single
* alert can answer. Reviews are recorded against the alert count seen, so a customer leaves the
* queue once reviewed and only returns when the engine flags them again.
*/
@Component({
selector: 'mifosx-aml-recurring-alerts',
templateUrl: './aml-recurring-alerts.component.html',
styleUrls: ['./aml-recurring-alerts.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
MatFormFieldModule,
MatSelectModule,
MatCheckboxModule,
MatProgressSpinnerModule,
MatDialogModule,
HasPermissionDirective
]
})
export class AmlRecurringAlertsComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
rows = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'customer',
'alertCount',
'severity',
'rules',
'totalAmount',
'lastAlert',
'reviewState',
'actions'
];
/**
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD, which already treats 3 cumulative
* AML events as the point a customer's risk rating is reconsidered.
*/
minAlerts = 3;
windowDays = 90;
onlyNeedingReview = false;
readonly thresholdOptions = [
2,
3,
5,
10
];
readonly windowOptions = [
{ value: 30, label: 'Last 30 days' },
{ value: 90, label: 'Last 90 days' },
{ value: 180, label: 'Last 6 months' },
{ value: 365, label: 'Last 12 months' }
];
loading = false;
loadFailed = false;
/** Which customer's alert detail is expanded inline. */
expandedClientId: number | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadFailed = false;
this.expandedClientId = null;
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
next: (data) => {
this.loading = false;
this.rows.data = this.mapRows(data || []);
},
error: () => {
this.loading = false;
this.loadFailed = true;
// An AML review queue shows what the engine found or nothing at all. Standing in sample
// customers when the call fails invents suspicious activity against names that are not
// the bank's, which is worse than an empty table saying the load failed.
this.rows.data = [];
}
});
}
/** Parses the date fields the template renders. */
private mapRows(rows: any[]): any[] {
return rows.map((r: any) => ({
...r,
firstAlertOnDate: this.toDate(r.firstAlertOn),
lastAlertOnDate: this.toDate(r.lastAlertOn),
lastReviewedOnDate: this.toDate(r.lastReviewedOn),
alerts: (r.alerts || []).map((a: any) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
}));
}
toggleExpanded(row: any): void {
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
}
isExpanded(row: any): boolean {
return this.expandedClientId === row.clientId;
}
get needingReviewCount(): number {
return this.rows.data.filter((r) => r.needsReview).length;
}
review(row: any): void {
const dialogRef = this.dialog.open(AmlRecurringReviewDialogComponent, {
width: '780px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { row, windowDays: this.windowDays }
});
dialogRef.afterClosed().subscribe((outcome: string | undefined) => {
if (!outcome) {
return;
}
this.alertService.alert({
type: 'Review Recorded',
message: `${row.clientDisplayName} reviewed as ${outcome}.`
});
// Re-read: the review changes needsReview, and the count recorded server-side is what the
// row must now reflect.
this.load();
});
}
amount(value: any): string {
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
}
riskLabel(category: any): string {
switch (Number(category)) {
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
default:
return 'Unrated';
}
}
riskClass(category: any): string {
switch (Number(category)) {
case 1:
return 'ra-risk ra-risk-low';
case 2:
return 'ra-risk ra-risk-medium';
case 3:
return 'ra-risk ra-risk-high';
default:
return 'ra-risk ra-risk-none';
}
}
/**
* Fineract's Gson adapter emits LocalDateTime as [y, M, d, H, m, s] rather than an ISO string, so
* the date pipe renders nothing unless it is converted first. Month is 1-based there, 0-based in Date.
*/
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
y,
M,
d,
H = 0,
m = 0,
s = 0
] = value;
if (y == null || M == null || d == null) {
return null;
}
return new Date(y, M - 1, d, H, m, s);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}