feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
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

- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports.
- Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons.
- Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars.
- Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval.
- Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names.
- Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks.
- Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
This commit is contained in:
2026-08-14 10:44:00 +05:30
parent 8d67a22f18
commit 352a65cb5d
22 changed files with 2219 additions and 61 deletions
@@ -1,3 +1,11 @@
/**
* 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';
@@ -17,10 +25,13 @@ import {
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',
@@ -51,19 +62,29 @@ import { SanctionsLogsService } from './sanctions-logs.service';
})
export class SanctionsLogsComponent implements OnInit {
private sanctionsLogsService = inject(SanctionsLogsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
displayedColumns: string[] = [
'screenedName',
'screeningResult',
'disposition',
'matchCount',
'screenedBy',
'screenedAt'
'screenedAt',
'actions'
];
dataSource = new MatTableDataSource<any>();
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;
loading = false;
rescreening = false;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -74,6 +95,20 @@ export class SanctionsLogsComponent implements OnInit {
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.
const request$ = this.alertFilter
? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter)
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
request$.subscribe({
next: (data: any) => this.applyRows(data || []),
error: () => this.applyRows([])
});
}
private buildLogFilters() {
const filters: any = {};
if (this.activeFilter) {
filters.result = this.activeFilter;
@@ -81,35 +116,102 @@ export class SanctionsLogsComponent implements OnInit {
if (this.searchName?.trim()) {
filters.name = this.searchName.trim();
}
this.sanctionsLogsService.getLogs(filters).subscribe({
next: (data: any) => {
this.dataSource = new MatTableDataSource(data || []);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
},
error: () => {
this.dataSource = new MatTableDataSource([]);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
});
return filters;
}
private applyRows(rows: any[]) {
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
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.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 });
}
});
}
}