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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user