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
@@ -0,0 +1,349 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DipLookupsService } from './dip-lookups.service';
/**
* Records — or displays — the officer's decision on a flagged DIP/MNRL lookup.
*
* The same decision can be taken inline during onboarding; this is the back-office path for
* numbers that were left undecided, and the read-only view once a decision exists.
*/
@Component({
selector: 'mifosx-dip-decision-dialog',
template: `
<h2 mat-dialog-title>
<fa-icon icon="satellite-dish" class="title-icon"></fa-icon>
Mobile Lookup #{{ row.id }}
</h2>
<mat-dialog-content>
<div class="detail-grid">
<div class="detail-row">
<span class="detail-label">Mobile Number</span>
<span class="detail-value strong">{{ row.mobileNumber }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value" [class.danger]="row.lookupStatus !== 'CLEAN'">{{ row.lookupStatus }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Result</span>
<span class="detail-value">{{ row.responseMessage || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Source</span>
<span class="detail-value">{{ row.lookupSource || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Client</span>
<span class="detail-value">{{ row.clientId || 'Not onboarded' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Looked Up</span>
<span class="detail-value">{{ row.lookedUpOn | date: 'medium' }}</span>
</div>
</div>
@if (row.overrideDecision) {
<div class="section-title">Recorded Decision</div>
<div class="decision-card" [class.decision-rejected]="row.overrideDecision === 'REJECTED'">
<div class="decision-badge">
{{ row.overrideDecision === 'PROCEEDED' ? 'PROCEEDED' : 'REJECTED' }}
</div>
<p class="decision-reason">{{ row.overrideReason || 'No reason recorded.' }}</p>
<div class="decision-meta">
Decided by user {{ row.decidedBy || '—' }} on {{ row.decidedOn | date: 'medium' }}
</div>
</div>
} @else {
<div class="section-title">Record a Decision</div>
<div class="pending-note">
<fa-icon icon="exclamation-circle"></fa-icon>
This number is flagged and has not been decided. It cannot be saved on a client until an officer proceeds with
reasons.
</div>
@if (showReasonForm) {
<div class="reason-form">
<label class="field-label">Why is this number acceptable despite the DIP/MNRL result?</label>
<textarea
class="field-input"
rows="3"
[(ngModel)]="reason"
placeholder="e.g. Number re-issued to the applicant on 02/2026; ownership confirmed against the operator's CAF and a fresh OTP."
></textarea>
<div class="field-hint" [class.field-error]="touched && !reasonValid">
Minimum 10 characters — this reason is kept on the lookup record.
</div>
</div>
}
}
@if (errorMessage) {
<div class="error-note">{{ errorMessage }}</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
@if (!row.overrideDecision && !showReasonForm) {
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject Number</button>
<button mat-raised-button color="primary" (click)="showReasonForm = true" [disabled]="saving">
Proceed with Reason
</button>
}
@if (!row.overrideDecision && showReasonForm) {
<button mat-raised-button color="primary" (click)="proceed()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Decision' }}
</button>
}
</mat-dialog-actions>
`,
styles: [
`
:host {
display: block;
min-width: 500px;
}
h2[mat-dialog-title] {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 16px 24px;
font-size: 18px;
font-weight: 600;
color: #1074b9;
}
mat-dialog-content {
padding: 16px 24px;
}
.detail-grid {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 14px;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
white-space: nowrap;
}
.detail-value {
font-size: 12px;
color: #374151;
font-weight: 600;
text-align: right;
}
.detail-value.strong {
color: #111827;
font-size: 13px;
}
.detail-value.danger {
color: #dc2626;
}
.section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #6b7280;
margin: 16px 0 6px;
}
.pending-note {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
line-height: 1.5;
}
.decision-card {
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-left: 4px solid #059669;
border-radius: 6px;
padding: 10px 14px;
}
.decision-card.decision-rejected {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
}
.decision-badge {
font-size: 11px;
font-weight: 800;
letter-spacing: 1px;
color: #059669;
}
.decision-rejected .decision-badge {
color: #dc2626;
}
.decision-reason {
font-size: 13px;
color: #374151;
line-height: 1.6;
margin: 6px 0;
white-space: pre-wrap;
}
.decision-meta {
font-size: 11px;
color: #9ca3af;
}
.reason-form {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
}
.field-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.field-input {
width: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
resize: vertical;
}
.field-input:focus {
outline: none;
border-color: #1074b9;
}
.field-hint {
font-size: 11px;
color: #9ca3af;
}
.field-error {
color: #dc2626;
font-weight: 600;
}
.error-note {
font-size: 12px;
font-weight: 600;
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 8px 12px;
margin-top: 12px;
}
mat-dialog-actions {
padding: 8px 24px 16px;
gap: 8px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
FaIconComponent
]
})
export class DipDecisionDialogComponent {
private dialogRef = inject(MatDialogRef<DipDecisionDialogComponent>);
private service = inject(DipLookupsService);
row: any = inject(MAT_DIALOG_DATA);
showReasonForm = false;
reason = '';
touched = false;
saving = false;
errorMessage = '';
private changed = false;
get reasonValid(): boolean {
return this.reason.trim().length >= 10;
}
proceed() {
this.touched = true;
if (!this.reasonValid) {
return;
}
this.save('PROCEEDED', this.reason.trim());
}
reject() {
this.save('REJECTED', 'Number rejected on review of the DIP/MNRL result.');
}
private save(decision: string, reason: string) {
this.saving = true;
this.errorMessage = '';
this.service.recordDecision(this.row.id, decision, reason).subscribe({
next: (updated: any) => {
this.saving = false;
this.changed = true;
this.row = updated || this.row;
this.showReasonForm = false;
},
error: (err: any) => {
this.saving = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.';
}
});
}
close() {
this.dialogRef.close(this.changed);
}
}
@@ -0,0 +1,131 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<fa-icon icon="satellite-dish" class="header-icon"></fa-icon>
<h2>DIP / MNRL Mobile Lookups</h2>
</div>
<div class="filters-row">
<mat-form-field class="search-field">
<mat-label>Search by Mobile Number</mat-label>
<input
matInput
[value]="searchNumber"
(keyup.enter)="applySearch($event.target.value)"
placeholder="e.g. 9827123456"
/>
<fa-icon matSuffix icon="search" class="search-icon"></fa-icon>
</mat-form-field>
<div class="filter-chips">
<button mat-stroked-button [class.active-filter]="!activeStatus && !pendingOnly" (click)="clearFilters()">
<fa-icon icon="list" class="chip-icon"></fa-icon> All
</button>
<button
mat-stroked-button
[class.active-filter]="activeStatus === 'CLEAN'"
class="chip-clean"
(click)="filterByStatus('CLEAN')"
>
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Clean
</button>
<button
mat-stroked-button
[class.active-filter]="activeStatus === 'SUSPECTED'"
class="chip-flagged"
(click)="filterByStatus('SUSPECTED')"
>
<fa-icon icon="exclamation-triangle" class="chip-icon"></fa-icon> Suspected
</button>
<button
mat-stroked-button
[class.active-filter]="activeStatus === 'SERVICE_UNAVAILABLE'"
class="chip-pending"
(click)="filterByStatus('SERVICE_UNAVAILABLE')"
>
<fa-icon icon="unlink" class="chip-icon"></fa-icon> Not Verified
</button>
<!-- The queue that matters: flagged numbers nobody has decided on yet. -->
<button mat-stroked-button [class.active-filter]="pendingOnly" class="chip-pending" (click)="showPending()">
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Decision
</button>
@if (searchNumber || activeStatus || pendingOnly) {
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
}
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="mobileNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Mobile Number</th>
<td mat-cell *matCellDef="let row">{{ row.mobileNumber }}</td>
</ng-container>
<ng-container matColumnDef="lookupStatus">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
<td mat-cell *matCellDef="let row">
<span class="result-badge" [ngClass]="statusClass(row)">{{ row.lookupStatus }}</span>
</td>
</ng-container>
<ng-container matColumnDef="decision">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Decision</th>
<td mat-cell *matCellDef="let row">
<span class="result-badge" [ngClass]="decisionClass(row)">{{ decisionLabel(row) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="lookupSource">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Source</th>
<td mat-cell *matCellDef="let row">{{ row.lookupSource || '—' }}</td>
</ng-container>
<ng-container matColumnDef="clientId">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client</th>
<td mat-cell *matCellDef="let row">{{ row.clientId || 'Not onboarded' }}</td>
</ng-container>
<ng-container matColumnDef="lookedUpOn">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Looked Up</th>
<td mat-cell *matCellDef="let row">{{ row.lookedUpOn | date: 'medium' }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Review</th>
<td mat-cell *matCellDef="let row">
@if (needsDecision(row) || row.overrideDecision) {
<button mat-stroked-button color="primary" class="review-btn" (click)="review(row)">
<fa-icon icon="search"></fa-icon> {{ needsDecision(row) ? 'Decide' : 'View' }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="select-row"></tr>
</table>
@if (!loading && dataSource.data.length === 0) {
<div class="no-data">
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
<p>No mobile lookups found.</p>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
.page-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
.header-icon {
font-size: 24px;
color: #1074b9;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
}
.filters-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
.search-field {
flex: 1;
min-width: 250px;
max-width: 400px;
}
.search-icon {
color: #999;
margin-right: 4px;
}
}
.filter-chips {
display: flex;
align-items: center;
gap: 8px;
button {
font-size: 13px;
border-radius: 20px;
}
.chip-icon {
margin-right: 4px;
font-size: 12px;
}
.active-filter {
background-color: #1074b9;
color: #fff;
border-color: #1074b9;
}
.chip-clean.active-filter {
background-color: #388e3c;
border-color: #388e3c;
}
.chip-flagged.active-filter {
background-color: #d32f2f;
border-color: #d32f2f;
}
.chip-pending.active-filter {
background-color: #b45309;
border-color: #b45309;
}
}
.result-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.badge-clean {
background: #e8f5e9;
color: #2e7d32;
}
.badge-flagged {
background: #ffebee;
color: #c62828;
}
/* Reviewed but not yet signed off by the Principal Officer — still blocking. */
.badge-pending {
background: #fffbeb;
color: #b45309;
}
.muted {
color: #9ca3af;
}
.review-btn {
font-size: 12px;
border-radius: 16px;
fa-icon {
margin-right: 4px;
}
}
.select-row {
cursor: pointer;
&:hover {
background: #f5f5f5;
}
}
.no-data {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
color: #999;
.no-data-icon {
font-size: 40px;
margin-bottom: 8px;
}
p {
font-size: 14px;
margin: 0;
}
}
mat-progress-bar {
margin-bottom: 8px;
}
@@ -0,0 +1,178 @@
/**
* 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 { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { MatDialog } from '@angular/material/dialog';
import { NgClass, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DipLookupsService } from './dip-lookups.service';
import { DipDecisionDialogComponent } from './dip-decision-dialog.component';
/**
* DIP / MNRL lookup activity and the decision queue.
*
* Every mobile number screened against the DoT Digital Intelligence Platform is logged, but the
* data had no screen at all — a flagged number could sit undecided with nobody able to see it.
*/
@Component({
selector: 'mifosx-dip-lookups',
templateUrl: './dip-lookups.component.html',
styleUrls: ['./dip-lookups.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatProgressBarModule,
MatTooltip,
NgClass,
DatePipe,
FaIconComponent
]
})
export class DipLookupsComponent implements OnInit {
private service = inject(DipLookupsService);
private dialog = inject(MatDialog);
displayedColumns: string[] = [
'mobileNumber',
'lookupStatus',
'decision',
'lookupSource',
'clientId',
'lookedUpOn',
'actions'
];
dataSource = new MatTableDataSource<any>();
searchNumber = '';
activeStatus: string | null = null;
/** True while the table is showing the pending-decision queue rather than the full log. */
pendingOnly = false;
loading = false;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
ngOnInit() {
this.load();
}
load() {
this.loading = true;
const request$ = this.pendingOnly
? this.service.getPending()
: this.service.getLogs({
mobileNumber: this.searchNumber?.trim() || undefined,
status: this.activeStatus || undefined
});
request$.subscribe({
next: (rows: any) => this.applyRows(rows || []),
error: () => this.applyRows([])
});
}
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.searchNumber = value;
this.pendingOnly = false;
this.load();
}
filterByStatus(status: string | null) {
this.activeStatus = this.activeStatus === status ? null : status;
this.pendingOnly = false;
this.load();
}
showPending() {
this.pendingOnly = !this.pendingOnly;
this.activeStatus = null;
this.load();
}
clearFilters() {
this.searchNumber = '';
this.activeStatus = null;
this.pendingOnly = false;
this.load();
}
statusClass(row: any): string {
if (row?.lookupStatus === 'CLEAN') return 'badge-clean';
if (row?.lookupStatus === 'SERVICE_UNAVAILABLE') return 'badge-pending';
return 'badge-flagged';
}
decisionLabel(row: any): string {
if (row?.overrideDecision === 'PROCEEDED') return 'Proceeded';
if (row?.overrideDecision === 'REJECTED') return 'Rejected';
// Only a hit or an unverifiable lookup is actually awaiting anything; a clean number needs
// no decision at all, so it should not read as if something is outstanding.
return row?.suspected || row?.disconnected || row?.lookupStatus === 'SERVICE_UNAVAILABLE'
? 'Awaiting Decision'
: '—';
}
decisionClass(row: any): string {
if (row?.overrideDecision === 'PROCEEDED') return 'badge-clean';
if (row?.overrideDecision === 'REJECTED') return 'badge-flagged';
return this.needsDecision(row) ? 'badge-pending' : '';
}
needsDecision(row: any): boolean {
const flagged = row?.suspected || row?.disconnected || row?.lookupStatus === 'SERVICE_UNAVAILABLE';
return flagged && !row?.overrideDecision;
}
review(row: any) {
this.dialog
.open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false })
.afterClosed()
.subscribe((changed: boolean) => {
if (changed) {
this.load();
}
});
}
}
@@ -0,0 +1,38 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** DIP / MNRL mobile number lookup activity and officer decisions. */
@Injectable({ providedIn: 'root' })
export class DipLookupsService {
private http = inject(HttpClient);
getLogs(filters?: { mobileNumber?: string; status?: string }): Observable<any> {
let params = new HttpParams();
if (filters?.mobileNumber) {
params = params.set('mobileNumber', filters.mobileNumber);
}
if (filters?.status) {
params = params.set('status', filters.status);
}
return this.http.get('/lookup/logs', { params });
}
/** Flagged or unverifiable numbers on which no decision has been recorded yet. */
getPending(): Observable<any> {
return this.http.get('/lookup/pending');
}
/** @param decision 'PROCEEDED' | 'REJECTED' */
recordDecision(logId: number, decision: string, reason: string): Observable<any> {
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
}
}
@@ -43,6 +43,11 @@ const routes: Routes = [
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
component: SanctionsLogsComponent
},
{
path: 'dip-lookups',
data: { title: 'DIP / MNRL Lookups', breadcrumb: 'DIP / MNRL Lookups' },
loadComponent: () => import('./dip-lookups/dip-lookups.component').then((m) => m.DipLookupsComponent)
},
// Must sit above ':filter', which would otherwise match this path and
// render an empty report category instead.
{
+9
View File
@@ -14,6 +14,15 @@
<h2 class="rpt-header__title">{{ 'labels.menus.Reports' | translate }}</h2>
<span class="rpt-header__badge">{{ reportsData?.length || 0 }} reports</span>
</div>
<!--
Sanctions alerts are not a runnable report, so they never appear in the category tiles
below — yet this is where the Principal Officer has to come to clear a flagged screening.
Without this link the review queue was reachable only by typing the URL.
-->
<a class="rpt-header__action" routerLink="/reports/sanctions-logs">
<i class="fas fa-shield-alt"></i>
Sanctions Screening &amp; Alerts
</a>
</div>
<!-- ===== LEVEL 0: All Categories ===== -->
+23
View File
@@ -38,6 +38,29 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
margin: 0;
}
&__action {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 20px;
padding: 7px 16px;
transition:
background 0.2s ease,
border-color 0.2s ease;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
}
&__badge {
font-size: 11px;
font-family: $mono;
@@ -0,0 +1,430 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SanctionsLogsService } from './sanctions-logs.service';
/**
* Principal Officer review of a UNSC sanctions alert.
*
* This is the checker half of the flow: the branch officer records whether the hit is the same
* person, and nothing takes effect until it is approved here. Approving a false positive clears
* the name for onboarding; approving a confirmed match freezes the client's accounts and opens
* the FIU-IND / nodal officer reporting fields for the 24-hour obligation.
*/
@Component({
selector: 'mifosx-sanctions-alert-review-dialog',
template: `
<h2 mat-dialog-title>
<fa-icon icon="shield-alt" class="title-icon"></fa-icon>
Sanctions Alert #{{ alert.id }}
</h2>
<mat-dialog-content>
<div class="detail-grid">
<div class="detail-row">
<span class="detail-label">Screened Name</span>
<span class="detail-value strong">{{ alert.screenedName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Matched On List</span>
<span class="detail-value">{{ alert.matchedNames || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Matches</span>
<span class="detail-value">{{ alert.matchCount || 0 }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Source</span>
<span class="detail-value">{{ alert.screeningSource || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Client</span>
<span class="detail-value">{{ alert.clientId || 'Not yet onboarded' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Screened On</span>
<span class="detail-value">{{ alert.screenedOn | date: 'medium' }}</span>
</div>
</div>
<div class="section-title">Reviewing Officer's Assessment</div>
@if (alert.disposition === 'PENDING_REVIEW') {
<div class="pending-note">
<fa-icon icon="exclamation-circle"></fa-icon>
Not yet reviewed. The branch officer must record an outcome before this can be approved.
</div>
} @else {
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
<div class="assessment-badge">{{ dispositionLabel }}</div>
<p class="assessment-reason">{{ alert.dispositionReason || 'No reason recorded.' }}</p>
<div class="assessment-meta">
Reviewed by user {{ alert.reviewedBy || '—' }} on {{ alert.reviewedOn | date: 'medium' }}
</div>
</div>
}
@if (alert.approvedBy) {
<div class="approved-note">
<fa-icon icon="check-circle"></fa-icon>
Approved by user {{ alert.approvedBy }} on {{ alert.approvedOn | date: 'medium' }}
@if (alert.frozenAccountIds) {
<span> &middot; Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
}
</div>
}
@if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
<div class="section-title">FIU-IND / Nodal Officer Reporting</div>
<div class="report-form">
<label class="field-label">FIU-IND STR reference</label>
<input class="field-input" [(ngModel)]="fiuStrReference" placeholder="e.g. STR/2026/000412" />
<label class="field-label">Nodal officer informed on</label>
<input class="field-input" type="date" [(ngModel)]="nodalOfficerInformedOn" />
<div class="field-hint">
Section 51-A UAPA requires FIU-IND and the nodal officer to be informed within 24 hours of confirming the
match.
</div>
<button mat-stroked-button (click)="saveReporting()" [disabled]="saving" class="report-btn">
{{ saving ? 'Saving…' : 'Save Reporting Details' }}
</button>
@if (savedMessage) {
<div class="saved-note">
<fa-icon icon="check-circle"></fa-icon>
{{ savedMessage }}
</div>
}
</div>
}
@if (errorMessage) {
<div class="error-note">{{ errorMessage }}</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
@if (canApprove) {
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
<fa-icon icon="check"></fa-icon>
{{ saving ? 'Approving…' : approveLabel }}
</button>
}
</mat-dialog-actions>
`,
styles: [
`
:host {
display: block;
min-width: 520px;
}
h2[mat-dialog-title] {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 16px 24px;
font-size: 18px;
font-weight: 600;
color: #1074b9;
}
mat-dialog-content {
padding: 16px 24px;
}
.detail-grid {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 14px;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 6px 0;
border-bottom: 1px solid #f3f4f6;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
white-space: nowrap;
}
.detail-value {
font-size: 12px;
color: #374151;
font-weight: 600;
text-align: right;
}
.detail-value.strong {
color: #111827;
font-size: 13px;
}
.section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #6b7280;
margin: 16px 0 6px;
}
.pending-note {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
}
.assessment-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 4px solid #1074b9;
border-radius: 6px;
padding: 10px 14px;
}
.assessment-card.assessment-confirmed {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
}
.assessment-badge {
font-size: 11px;
font-weight: 800;
letter-spacing: 1px;
color: #1074b9;
}
.assessment-confirmed .assessment-badge {
color: #dc2626;
}
.assessment-reason {
font-size: 13px;
color: #374151;
line-height: 1.6;
margin: 6px 0;
white-space: pre-wrap;
}
.assessment-meta {
font-size: 11px;
color: #9ca3af;
}
.approved-note {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 6px;
padding: 8px 12px;
margin-top: 10px;
}
.report-form {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-size: 12px;
font-weight: 600;
color: #374151;
margin-top: 6px;
}
.field-input {
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.field-input:focus {
outline: none;
border-color: #1074b9;
}
.field-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.report-btn {
align-self: flex-start;
margin-top: 8px;
}
.saved-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 6px;
padding: 8px 12px;
margin-top: 8px;
}
.error-note {
font-size: 12px;
font-weight: 600;
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 8px 12px;
margin-top: 12px;
}
mat-dialog-actions {
padding: 8px 24px 16px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
FaIconComponent
]
})
export class SanctionsAlertReviewDialogComponent {
private dialogRef = inject(MatDialogRef<SanctionsAlertReviewDialogComponent>);
private service = inject(SanctionsLogsService);
alert: any = inject(MAT_DIALOG_DATA);
fiuStrReference = '';
nodalOfficerInformedOn = '';
saving = false;
errorMessage = '';
savedMessage = '';
/** Set when anything was persisted, so the caller knows to refresh the table. */
private changed = false;
constructor() {
this.fiuStrReference = this.alert?.fiuStrReference || '';
this.nodalOfficerInformedOn = this.alert?.nodalOfficerInformedOn
? String(this.alert.nodalOfficerInformedOn).substring(0, 10)
: '';
}
get dispositionLabel(): string {
return this.alert?.disposition === 'FALSE_POSITIVE' ? 'NOT THE SAME PERSON' : 'CONFIRMED MATCH';
}
/** Only a reviewed, not-yet-approved alert is actionable here. */
get canApprove(): boolean {
return this.alert?.disposition && this.alert.disposition !== 'PENDING_REVIEW' && !this.alert.approvedBy;
}
get approveLabel(): string {
return this.alert?.disposition === 'FALSE_POSITIVE'
? 'Approve — Clear for Onboarding'
: 'Approve — Confirm & Freeze';
}
approve() {
this.saving = true;
this.errorMessage = '';
this.savedMessage = '';
this.service.approveAlert(this.alert.id).subscribe({
next: (updated: any) => {
this.saving = false;
this.changed = true;
this.alert = updated || this.alert;
},
error: (err: any) => {
this.saving = false;
this.errorMessage = this.extractMessage(err, 'Could not approve this alert.');
}
});
}
saveReporting() {
this.errorMessage = '';
this.savedMessage = '';
const strReference = this.fiuStrReference.trim();
if (strReference.length < 4) {
this.errorMessage = 'Enter the acknowledgement reference FIU-IND returned for the STR.';
return;
}
if (!this.nodalOfficerInformedOn) {
this.errorMessage = 'Enter the date the nodal officer was informed.';
return;
}
// The intimation cannot predate the screening that triggered it — a backdated entry here is
// exactly the kind of record a regulator would treat as fabricated.
const screenedOn = this.alert?.screenedOn ? new Date(this.alert.screenedOn) : null;
if (screenedOn && !isNaN(screenedOn.getTime())) {
const screenedDay = new Date(screenedOn.getFullYear(), screenedOn.getMonth(), screenedOn.getDate());
if (new Date(this.nodalOfficerInformedOn) < screenedDay) {
this.errorMessage = 'The nodal officer cannot have been informed before the match was screened.';
return;
}
}
this.saving = true;
this.service.recordReporting(this.alert.id, strReference, this.nodalOfficerInformedOn).subscribe({
next: (updated: any) => {
this.saving = false;
this.changed = true;
this.alert = updated || this.alert;
this.savedMessage = 'Reporting details saved against this alert.';
},
error: (err: any) => {
this.saving = false;
this.errorMessage = this.extractMessage(err, 'Could not save the reporting details.');
}
});
}
close() {
this.dialogRef.close(this.changed);
}
private extractMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.message || err?.message || fallback;
}
}
@@ -39,12 +39,44 @@
>
<fa-icon icon="exclamation-triangle" class="chip-icon"></fa-icon> Flagged
</button>
@if (searchName || activeFilter) {
<!--
A flagged screening is a case that has to be closed out, so the officer needs a queue of
the ones still open — not just a list of everything ever screened.
-->
<button
mat-stroked-button
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
class="chip-pending"
(click)="filterByAlert('PENDING_REVIEW')"
>
<fa-icon icon="clock" class="chip-icon"></fa-icon> Pending Review
</button>
<button
mat-stroked-button
[class.active-filter]="alertFilter === 'ALL'"
class="chip-flagged"
(click)="filterByAlert('ALL')"
>
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
</button>
@if (searchName || activeFilter || alertFilter) {
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
mat-stroked-button
color="primary"
class="rescreen-btn"
[disabled]="rescreening"
matTooltip="Screen every existing client against the currently loaded UN list snapshot. This is what detects a customer added to the list after onboarding."
(click)="rescreenExistingClients()"
>
<fa-icon icon="sync" class="chip-icon"></fa-icon>
{{ rescreening ? 'Re-screening…' : 'Re-screen Existing Clients' }}
</button>
</div>
@if (loading) {
@@ -72,6 +104,17 @@
</td>
</ng-container>
<ng-container matColumnDef="disposition">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Disposition</th>
<td mat-cell *matCellDef="let log">
@if (isAlert(log)) {
<span class="result-badge" [ngClass]="dispositionClass(log)">{{ dispositionLabel(log) }}</span>
} @else {
<span class="muted">—</span>
}
</td>
</ng-container>
<ng-container matColumnDef="matchCount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Matches</th>
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
@@ -84,7 +127,19 @@
<ng-container matColumnDef="screenedAt">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened At</th>
<td mat-cell *matCellDef="let log">{{ log.screenedAt | date: 'medium' }}</td>
<!-- The API field is screenedOn; the old screenedAt binding rendered an empty column. -->
<td mat-cell *matCellDef="let log">{{ log.screenedOn | date: 'medium' }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Review</th>
<td mat-cell *matCellDef="let log">
@if (isAlert(log)) {
<button mat-stroked-button color="primary" class="review-btn" (click)="reviewAlert(log)">
<fa-icon icon="search"></fa-icon> Review
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
@@ -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/.
*/
.page-header {
display: flex;
align-items: center;
@@ -66,6 +74,22 @@
background-color: #d32f2f;
border-color: #d32f2f;
}
.chip-pending.active-filter {
background-color: #b45309;
border-color: #b45309;
}
}
.rescreen-btn {
font-size: 13px;
border-radius: 20px;
margin-left: auto;
.chip-icon {
margin-right: 4px;
font-size: 12px;
}
}
.result-badge {
@@ -89,6 +113,25 @@
color: #c62828;
}
/* Reviewed but not yet signed off by the Principal Officer — still blocking. */
.badge-pending {
background: #fffbeb;
color: #b45309;
}
.muted {
color: #9ca3af;
}
.review-btn {
font-size: 12px;
border-radius: 16px;
fa-icon {
margin-right: 4px;
}
}
.select-row {
cursor: pointer;
@@ -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 });
}
});
}
}
@@ -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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
@@ -16,4 +24,39 @@ export class SanctionsLogsService {
}
return this.http.get('/sanctions/logs', { params });
}
/**
* Flagged screenings and their disposition state — the Principal Officer's review queue.
* @param status PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH, or omitted for all alerts
*/
getAlerts(status?: string): Observable<any> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get('/sanctions/alerts', { params });
}
/**
* Principal Officer sign-off. Approving a false positive clears the name for onboarding;
* approving a confirmed match freezes debits on the client's active deposit accounts.
*/
approveAlert(logId: number): Observable<any> {
return this.http.post(`/sanctions/alerts/${logId}/approve`, {});
}
/** Sends the alert back for a fresh assessment by overwriting its disposition. */
recordDisposition(logId: number, disposition: string, reason: string): Observable<any> {
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
}
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
recordReporting(logId: number, fiuStrReference: string, nodalOfficerInformedOn: string): Observable<any> {
return this.http.post(`/sanctions/alerts/${logId}/report`, { fiuStrReference, nodalOfficerInformedOn });
}
/** Re-screens every existing client against the currently loaded list snapshot. */
rescreenExistingClients(): Observable<any> {
return this.http.post('/sanctions/rescreen', {});
}
}