/** * 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([]); 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; } }