/** * 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/. */ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatTableDataSource, MatTable, MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow } from '@angular/material/table'; import { MatChipsModule } from '@angular/material/chips'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { NgClass, DatePipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { SanctionsLogsService } from './sanctions-logs.service'; import { SanctionsAlertReviewDialogComponent } from './sanctions-alert-review-dialog.component'; @Component({ selector: 'mifosx-sanctions-logs', templateUrl: './sanctions-logs.component.html', styleUrls: ['./sanctions-logs.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatTable, MatSort, MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatSortHeader, MatCellDef, MatCell, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow, MatPaginator, MatChipsModule, MatProgressBarModule, MatTooltip, NgClass, DatePipe, FaIconComponent ] }) export class SanctionsLogsComponent implements OnInit { private sanctionsLogsService = inject(SanctionsLogsService); private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); displayedColumns: string[] = [ 'screenedName', 'screeningResult', 'disposition', 'matchCount', 'screeningSource', 'client', 'screenedBy', 'screenedAt', 'actions' ]; dataSource = new MatTableDataSource(); searchName = ''; activeFilter: string | null = null; /** * When set, the table shows the Principal Officer's alert queue (flagged screenings and their * disposition) instead of the raw screening log. 'ALL' means every alert regardless of state. */ alertFilter: string | null = null; /** * Which screening run the rows came from: 'CLIENT_CREATION' for names screened while the client was being * created, 'RESCREEN' for the periodic sweep over existing clients, null for both. Applied in the browser * because neither the log nor the alert endpoint accepts a source filter. */ activeSource: 'CLIENT_CREATION' | 'RESCREEN' | null = null; loading = false; rescreening = false; /** Full result set, kept so the source filter can be applied without another round trip. */ private allRows: any[] = []; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; ngOnInit() { this.loadLogs(); } loadLogs() { this.loading = true; // The alert queue is a different endpoint, not a filter on the log list: it carries the // disposition columns the review dialog needs. // AWAITING_APPROVAL is a client-side view over all alerts rather than a disposition value: // an alert waiting on the Principal Officer is one that HAS been assessed but not signed off, // so it is identified by approvedBy being empty, not by its disposition. const serverFilter = this.alertFilter === 'ALL' || this.alertFilter === 'AWAITING_APPROVAL' ? undefined : this.alertFilter; const request$ = this.alertFilter ? this.sanctionsLogsService.getAlerts(serverFilter) : this.sanctionsLogsService.getLogs(this.buildLogFilters()); request$.subscribe({ next: (data: any) => this.applyRows(this.postFilter(data || [])), error: () => this.applyRows([]) }); } /** Narrows the fetched alerts for views the API has no dedicated filter for. */ private postFilter(rows: any[]): any[] { if (this.alertFilter !== 'AWAITING_APPROVAL') { return rows; } return rows.filter((row) => this.isAwaitingApproval(row)); } /** * True when the clerk has recorded an assessment and the Principal Officer has not yet approved * it. This is the state in which the freeze is pending and nothing happens until someone acts. */ isAwaitingApproval(row: any): boolean { return !!row?.disposition && row.disposition !== 'PENDING_REVIEW' && !row.approvedBy; } private buildLogFilters() { const filters: any = {}; if (this.activeFilter) { filters.result = this.activeFilter; } if (this.searchName?.trim()) { filters.name = this.searchName.trim(); } return filters; } private applyRows(rows: any[]) { this.allRows = rows; this.refreshTable(); this.loading = false; } /** Applies the source filter over the rows already fetched. */ private refreshTable() { const rows = this.activeSource ? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row))) : this.allRows; this.dataSource = new MatTableDataSource(rows); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; } /** * A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was * screened as the name was entered during client creation. */ isRescreen(row: any): boolean { return (row?.screeningSource || '').toUpperCase().includes('RESCREEN'); } sourceLabel(row: any): string { return this.isRescreen(row) ? 'Re-screening' : 'Client Creation'; } sourceClass(row: any): string { return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding'; } filterBySource(source: 'CLIENT_CREATION' | 'RESCREEN' | null) { this.activeSource = this.activeSource === source ? null : source; this.refreshTable(); } /** Row counts behind each source chip, so the split is visible before filtering. */ get creationCount(): number { return this.allRows.filter((row) => !this.isRescreen(row)).length; } get rescreenCount(): number { return this.allRows.filter((row) => this.isRescreen(row)).length; } applySearch(value: string) { this.searchName = value; // Name search only exists on the log endpoint, so leave the alert queue when searching. this.alertFilter = null; this.loadLogs(); } filterByResult(result: string | null) { this.activeFilter = this.activeFilter === result ? null : result; this.alertFilter = null; this.loadLogs(); } filterByAlert(status: string) { this.alertFilter = this.alertFilter === status ? null : status; this.activeFilter = null; this.loadLogs(); } clearFilters() { this.searchName = ''; this.activeFilter = null; this.alertFilter = null; this.activeSource = null; this.loadLogs(); } /** Only flagged rows carry a case to review. */ isAlert(row: any): boolean { return !!row?.disposition; } dispositionLabel(row: any): string { switch (row?.disposition) { case 'PENDING_REVIEW': return 'Pending Review'; case 'FALSE_POSITIVE': return row.approvedBy ? 'False Positive — Approved' : 'False Positive — Awaiting PO'; case 'CONFIRMED_MATCH': if (!row.approvedBy) { return 'Confirmed — Awaiting PO'; } // An onboarding match has no client and therefore no accounts to freeze — saying // "Frozen" there would claim an action that never happened. return row.frozenAccountIds ? 'Confirmed — Frozen' : 'Confirmed — Blocked'; default: return '—'; } } dispositionClass(row: any): string { if (row?.disposition === 'CONFIRMED_MATCH') return 'badge-flagged'; if (row?.disposition === 'FALSE_POSITIVE') return row.approvedBy ? 'badge-clear' : 'badge-pending'; if (row?.disposition === 'PENDING_REVIEW') return 'badge-pending'; return ''; } reviewAlert(row: any) { this.dialog .open(SanctionsAlertReviewDialogComponent, { width: '600px', data: { ...row }, autoFocus: false }) .afterClosed() .subscribe((changed: boolean) => { if (changed) { this.loadLogs(); } }); } /** * Screens every existing client against the currently loaded list snapshot. This is what * detects a customer who was added to the UN list after they were onboarded — the only * situation in which there are accounts to freeze. */ rescreenExistingClients() { this.rescreening = true; this.sanctionsLogsService.rescreenExistingClients().subscribe({ next: (res: any) => { this.rescreening = false; this.snackBar.open(`Re-screening complete — ${res?.clientsFlagged ?? 0} client(s) flagged.`, 'Close', { duration: 6000 }); this.filterByAlert('PENDING_REVIEW'); }, error: (err: any) => { this.rescreening = false; this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 }); } }); } }