feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
- 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:
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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 & Alerts
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== LEVEL 0: All Categories ===== -->
|
||||
|
||||
@@ -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> · 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', {});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user