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
@@ -163,7 +163,7 @@
}}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
<button
mat-stroked-button
color="primary"
@@ -173,27 +173,27 @@
(click)="verifySanctions()"
>
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
Verify Entity
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
</button>
}
@if (sanctionsVerified) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
<mat-icon
class="sanctions-info-icon"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
}
@if (sanctionsBlocked) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status blocked">
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
</span>
<mat-icon
class="sanctions-info-icon blocked-info"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
@@ -290,7 +290,7 @@
<span class="full-name-value">{{ fullNamePreview }}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
<button
mat-stroked-button
color="primary"
@@ -300,27 +300,27 @@
(click)="verifySanctions()"
>
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
Verify Client
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
</button>
}
@if (sanctionsVerified) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
<mat-icon
class="sanctions-info-icon"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
}
@if (sanctionsBlocked) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status blocked">
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
</span>
<mat-icon
class="sanctions-info-icon blocked-info"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
@@ -334,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
alternateDipLookupLogId: number | null = null;
/** Alternate DIP lookup timestamp from API */
alternateDipTimestamp: string | null = null;
/**
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
* from one that genuinely passed.
*/
dipStatus: string | null = null;
alternateDipStatus: string | null = null;
/** Officer rejected the number outright — it must be replaced before saving. */
dipRejected = false;
alternateDipRejected = false;
/**
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
@@ -530,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsVerified = false;
/** Sanctions match was found — blocks account opening */
sanctionsBlocked = false;
/**
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
*/
sanctionsDisposition: string | null = null;
/**
* The name was edited after a block, so the applicant may no longer be the flagged one.
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
*/
sanctionsNeedsRescreen = false;
/** Screening log ID from sanctions API */
screeningLogId: number | null = null;
/** Cites the direction actually in force — kept in step with the verification dialog. */
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
readonly sanctionsTooltip =
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
/** Badge text reflecting how far the flagged alert has progressed. */
get sanctionsStatusLabel(): string {
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
return 'Awaiting PO Approval';
}
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
return 'Confirmed Match';
}
return 'Sanctions Match';
}
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
@@ -665,13 +704,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.mobileVerified = false;
this.dipLookupLogId = null;
this.dipTimestamp = null;
this.dipStatus = null;
this.dipRejected = false;
this.alternateMobileVerified = false;
this.alternateDipLookupLogId = null;
this.alternateDipTimestamp = null;
this.alternateDipStatus = null;
this.alternateDipRejected = false;
this.mobileNewOtpId = null;
this.emailNewOtpId = null;
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.screeningLogId = null;
this.verifiedName = '';
}
@@ -1268,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.mobileVerified = false;
// The rejection applied to the previous number, not this one. Leaving it set would
// keep Save & Next dead with a message about a number no longer in the field.
this.dipRejected = false;
this.dipStatus = null;
this.dipLookupLogId = null;
// Also reset create-time contact-OTP verification — an OTP verified against a
// previous mobile number no longer applies once the value is edited. Belt-and-braces
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
@@ -1282,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.alternateMobileVerified = false;
this.alternateDipRejected = false;
this.alternateDipStatus = null;
this.alternateDipLookupLogId = null;
});
// Reset create-time contact-OTP verification when email address changes — see the
@@ -1322,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.mobileVerified = true;
}
// A flagged number the officer chose to proceed with counts as verified — the decision and
// its reason are already stored against the lookup, which is what the save gate checks.
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.dipRejected = result.decision === 'REJECTED';
this.dipStatus = result.status || null;
this.dipLookupLogId = result.lookupLogId || null;
this.dipTimestamp = result.timestamp || null;
});
@@ -1342,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.alternateMobileVerified = true;
}
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.alternateDipRejected = result.decision === 'REJECTED';
this.alternateDipStatus = result.status || null;
this.alternateDipLookupLogId = result.lookupLogId || null;
this.alternateDipTimestamp = result.timestamp || null;
});
@@ -1387,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
*/
verifySanctions() {
const name = this.currentNameSnapshot();
const dob = this.createClientForm.get('dateOfBirth')?.value;
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px',
data: { name }
width: '620px',
data: {
name,
// The dialog compares these against the listed particulars — a name-only hit cannot be
// judged without the applicant's own identifiers next to it.
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
nationality: this.nationalityLabel()
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
@@ -1399,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found
this.sanctionsVerified = true;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = name;
} else if (result.verified === false) {
// FLAGGED — match found, block account opening
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
// Officer, not a clearance; only their approval lets this name through.
this.sanctionsVerified = false;
this.sanctionsBlocked = true;
this.sanctionsDisposition = result.disposition || null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = '';
}
});
}
/** Human-readable nationality for the sanctions comparison panel. */
private nationalityLabel(): string {
return this.getSelectedNationality()?.label || '';
}
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
@@ -1448,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
// Editing the name forces a fresh screening, but must NOT silently clear an existing
// block: otherwise a flagged applicant is waved through by nudging one character. The
// block is only lifted by re-running the check and getting a CLEAR back from the API.
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
if (this.sanctionsBlocked) {
this.sanctionsNeedsRescreen = true;
}
}
});
}
@@ -1,8 +1,17 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from 'app/clients/clients.service';
@@ -31,13 +40,18 @@ import { ClientsService } from 'app/clients/clients.service';
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
</div>
}
<!--
An unreachable DoT service means the number was NOT checked. Reporting that as a green
"verification completed successfully — number is CLEAN" claimed a check that never ran and
stored the client as verified, so the outage is now shown for what it is.
-->
@if (!verifying && serviceUnavailable) {
<div class="verify-result">
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
<div class="success-detail">
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
<div class="verify-result unavailable">
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
<div class="unavailable-detail">
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
the service is reachable, or proceed with a recorded reason.
</div>
</div>
}
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
<p class="result-msg error-msg">{{ error }}</p>
</div>
}
<!--
A number turned away before is a standing signal. Without this the next officer screens
it as if it had no history and can wave through what a colleague already refused.
-->
@if (!verifying && priorRejection) {
<div class="prior-rejection">
<fa-icon icon="history" class="prior-icon"></fa-icon>
<div>
<strong>This number was rejected before.</strong>
<div class="prior-detail">
Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
status was {{ priorRejection.lookupStatus }}.
</div>
</div>
</div>
}
@if (!verifying && needsDecision && showReasonForm) {
<div class="reason-form">
<label class="reason-label">Why is this number acceptable despite the DIP/MNRL result?</label>
<textarea
class="reason-input"
rows="3"
[(ngModel)]="decisionReason"
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="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
Minimum 10 characters — this reason is kept on the lookup record.
</div>
@if (decisionError) {
<div class="reason-hint reason-error">{{ decisionError }}</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!verifying) {
@if (!verifying && !needsDecision) {
<button mat-raised-button color="primary" (click)="close()">OK</button>
}
<!--
A flagged number is a decision, not a notification: the officer must either reject it or
say in writing why it is acceptable. Previously a single OK dismissed the warning and the
number was onboarded with nothing recorded.
-->
@if (!verifying && needsDecision && !showReasonForm) {
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject this Number</button>
<button mat-raised-button color="primary" (click)="startProceed()" [disabled]="saving">
Proceed with Reason
</button>
}
@if (!verifying && needsDecision && showReasonForm) {
<button mat-stroked-button (click)="cancelProceed()" [disabled]="saving">Back</button>
<button mat-raised-button color="primary" (click)="submitProceed()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
</button>
}
</mat-dialog-actions>
`,
styles: [
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
color: #d32f2f;
}
.unavailable-icon {
color: #b45309;
}
.unavailable-msg {
color: #b45309;
}
.unavailable-detail {
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
line-height: 1.6;
margin-top: 8px;
}
.prior-rejection {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 13px;
color: #7f1d1d;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 12px;
margin-top: 12px;
line-height: 1.5;
}
.prior-icon {
color: #dc2626;
font-size: 16px;
margin-top: 2px;
}
.prior-detail {
font-size: 12px;
color: #991b1b;
margin-top: 2px;
}
.reason-form {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 14px;
}
.reason-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.reason-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;
}
.reason-input:focus {
outline: none;
border-color: #1074b9;
}
.reason-hint {
font-size: 11px;
color: #9ca3af;
}
.reason-error {
color: #dc2626;
font-weight: 600;
}
mat-dialog-actions {
padding: 8px 24px 16px;
gap: 8px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
suspectedStatus = '';
suspectedReason = '';
suspectedReportedBy = '';
/** Set when this number was rejected on an earlier client — history the officer must see. */
priorRejection: any = null;
/** Officer decision capture for a flagged or unverifiable number. */
showReasonForm = false;
decisionReason = '';
reasonTouched = false;
saving = false;
decisionError = '';
/** A hit or an unverified number cannot simply be dismissed with OK. */
get needsDecision(): boolean {
return this.suspected || this.serviceUnavailable;
}
get reasonValid(): boolean {
return this.decisionReason.trim().length >= 10;
}
private subscription: any;
private intervalId: any;
private apiResponse: any = null;
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
}
const res = this.apiResponse;
this.priorRejection = res?.priorRejection || null;
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
this.serviceUnavailable = true;
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
@@ -329,11 +502,68 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
}
}
startProceed() {
this.showReasonForm = true;
this.reasonTouched = false;
this.decisionError = '';
}
cancelProceed() {
this.showReasonForm = false;
this.decisionReason = '';
this.decisionError = '';
}
submitProceed() {
this.reasonTouched = true;
if (!this.reasonValid) {
return;
}
this.recordDecision('PROCEEDED', this.decisionReason.trim(), true);
}
reject() {
this.recordDecision('REJECTED', 'Number rejected at onboarding following DIP/MNRL result.', false);
}
/**
* Persists the decision before closing. The number is only usable downstream once the backend
* has stored PROCEEDED — the client-create gate re-checks this, so closing without saving
* would simply fail at save time with no explanation.
*/
private recordDecision(decision: string, reason: string, verified: boolean) {
const logId = this.apiResponse?.lookupLogId;
if (!logId) {
this.decisionError = 'Lookup reference missing — re-run the check before recording a decision.';
return;
}
this.saving = true;
this.decisionError = '';
this.clientsService.recordDipDecision(logId, decision, reason).subscribe({
next: () => {
this.saving = false;
this.dialogRef.close({
verified,
decision,
lookupLogId: logId,
status: this.apiResponse?.data?.status || null,
timestamp: this.apiResponse?.timestamp || null
});
},
error: (err: any) => {
this.saving = false;
this.decisionError =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.';
}
});
}
close() {
const verified = !this.suspected && !this.error;
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
this.dialogRef.close({
verified,
lookupLogId: this.apiResponse?.lookupLogId || null,
status: this.apiResponse?.data?.status || null,
timestamp: this.apiResponse?.timestamp || null
});
}
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from 'app/clients/clients.service';
@@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
</div>
}
</div>
<!--
The list matches on name alone, so a hit is a "stop and verify", not a verdict. The
officer has to be able to compare the listed particulars against the applicant's own
before deciding — showing only a red banner made that impossible.
-->
<div class="compare-header">
<fa-icon icon="search"></fa-icon>
Compare the applicant against the listed particulars
</div>
<div class="applicant-card">
<div class="applicant-label">Applicant</div>
<div class="applicant-values">
<span
><strong>{{ clientName }}</strong></span
>
<span>DOB: {{ applicantDob || '—' }}</span>
<span>Nationality: {{ applicantNationality || '—' }}</span>
</div>
</div>
<div class="match-list">
@for (m of matches; track m.id) {
<div class="match-item">
<div class="match-name">{{ m.fullName }}</div>
<div class="match-meta">
<span class="match-type">{{ m.entryType }}</span>
@if (m.unListType) {
<span class="match-list-type">{{ m.unListType }}</span>
}
@if (m.referenceNumber) {
<span class="match-ref">{{ m.referenceNumber }}</span>
}
</div>
<div class="match-aliases">
DOB: {{ m.dateOfBirth || '—' }} &middot; Nationality: {{ m.nationality || '—' }} &middot; Place of
birth: {{ m.placeOfBirth || '—' }}
</div>
@if (m.aliases?.length) {
<div class="match-aliases">Aliases: {{ m.aliases.join('; ') }}</div>
}
@if (m.listedOn) {
<div class="match-aliases">Listed on: {{ m.listedOn }}</div>
}
</div>
}
</div>
<div class="escalate-banner">
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
</div>
@if (showReasonForm) {
<div class="reason-form">
<label class="reason-label">
{{
pendingDisposition === 'FALSE_POSITIVE'
? 'Why is this NOT the same person? State the identifiers you compared.'
: 'Record the basis on which this match is confirmed.'
}}
</label>
<textarea
class="reason-input"
rows="3"
[(ngModel)]="dispositionReason"
placeholder="e.g. DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; passport number does not match."
></textarea>
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
Minimum 10 characters — this text is the compliance record.
</div>
@if (dispositionError) {
<div class="reason-hint reason-error">{{ dispositionError }}</div>
}
</div>
}
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
</button>
}
@if (!verifying && matchFound) {
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
<fa-icon icon="times"></fa-icon> Flag &amp; Escalate
</button>
@if (!showReasonForm) {
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
Not the Same Person
</button>
<button mat-raised-button color="warn" (click)="startDisposition('CONFIRMED_MATCH')" class="reject-btn">
<fa-icon icon="times"></fa-icon> Confirm Match &amp; Escalate
</button>
}
@if (showReasonForm) {
<button mat-stroked-button (click)="cancelDisposition()" [disabled]="savingDisposition">Back</button>
<button
mat-raised-button
[color]="pendingDisposition === 'FALSE_POSITIVE' ? 'primary' : 'warn'"
(click)="submitDisposition()"
[disabled]="savingDisposition"
class="reject-btn"
>
{{ savingDisposition ? 'Saving…' : 'Submit for Principal Officer Approval' }}
</button>
}
}
@if (!verifying && errorOccurred) {
<button mat-raised-button (click)="close(false)">Close</button>
@@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
mat-dialog-content {
padding: 16px 24px;
max-height: unset;
overflow: visible;
/*
The match card lists every hit with its aliases, so this content is now tall enough to
push the action row off-screen if the dialog is allowed to grow unbounded — which left
"Not the Same Person" unreachable. Scroll the body instead and keep the buttons pinned.
*/
max-height: 62vh;
overflow-y: auto;
}
.progress-section {
@@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
.match-list {
width: 100%;
max-height: unset;
overflow: visible;
margin: 8px 0;
}
@@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
margin-top: 4px;
}
.compare-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #6b7280;
margin-top: 4px;
}
.applicant-card {
width: 100%;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 4px solid #1074b9;
border-radius: 4px;
padding: 8px 12px;
}
.applicant-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #1074b9;
}
.applicant-values {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #374151;
margin-top: 2px;
}
.reason-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.reason-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.reason-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;
}
.reason-input:focus {
outline: none;
border-color: #1074b9;
}
.reason-hint {
font-size: 11px;
color: #9ca3af;
}
.reason-error {
color: #dc2626;
font-weight: 600;
}
.block-msg {
font-size: 14px;
font-weight: 600;
@@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
mat-dialog-actions {
padding: 8px 24px 16px;
/* Sits below a scrolling body, so mark it off clearly as the fixed action row. */
border-top: 1px solid #e5e7eb;
gap: 8px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
@@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
})
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
private data: { name: string } = inject(MAT_DIALOG_DATA);
private data: { name: string; dateOfBirth?: string; nationality?: string } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
clientName = '';
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
applicantDob = '';
applicantNationality = '';
verifying = true;
matchFound = false;
matchCount = 0;
@@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
/** Disposition capture — a hit must be closed out, not merely acknowledged. */
showReasonForm = false;
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
dispositionReason = '';
reasonTouched = false;
savingDisposition = false;
dispositionError = '';
/** Mirrors the backend's minimum: an unexplained assessment is not a review. */
get reasonValid(): boolean {
return this.dispositionReason.trim().length >= 10;
}
/**
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
@@ -599,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
ngOnInit() {
this.clientName = this.data?.name || '';
this.applicantDob = this.data?.dateOfBirth || '';
this.applicantNationality = this.data?.nationality || '';
this.dialogRef.disableClose = true;
// Start API call immediately
@@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
clearInterval(this.progressTimer);
}
startDisposition(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
this.pendingDisposition = disposition;
this.showReasonForm = true;
this.reasonTouched = false;
this.dispositionError = '';
}
cancelDisposition() {
this.showReasonForm = false;
this.pendingDisposition = null;
this.dispositionReason = '';
this.dispositionError = '';
}
submitDisposition() {
this.reasonTouched = true;
if (!this.reasonValid || !this.pendingDisposition) {
return;
}
// Without a log id there is nothing to attach the assessment to — close as a plain block
// rather than silently discarding the officer's reasoning.
if (!this.screeningLogId) {
this.dispositionError = 'Screening reference missing — re-run the check before recording an outcome.';
return;
}
this.savingDisposition = true;
this.clientsService
.recordSanctionsDisposition(this.screeningLogId, this.pendingDisposition, this.dispositionReason.trim())
.subscribe({
next: () => {
this.savingDisposition = false;
// Both outcomes keep the client blocked. A false positive only takes effect once the
// Principal Officer approves it from the Sanctions Logs screen.
this.dialogRef.close({
verified: false,
screeningLogId: this.screeningLogId,
disposition: this.pendingDisposition
});
},
error: (err: any) => {
this.savingDisposition = false;
this.dispositionError =
err?.error?.defaultUserMessage ||
err?.error?.message ||
err?.message ||
'Could not save the review outcome.';
}
});
}
close(verified: boolean) {
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
}
+24
View File
@@ -855,6 +855,30 @@ export class ClientsService {
return this.http.post('/sanctions/screen', { name });
}
/**
* Records the reviewing officer's assessment of a flagged screening.
*
* A FALSE_POSITIVE does not unblock onboarding on its own — it only becomes effective once
* the Principal Officer approves it from the Sanctions Logs screen.
*
* @param logId screening log id returned by screenSanctions()
* @param disposition 'FALSE_POSITIVE' | 'CONFIRMED_MATCH'
* @param reason which identifiers were compared and why they do/don't match
*/
recordSanctionsDisposition(logId: number, disposition: string, reason: string): Observable<any> {
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
}
/**
* Records the officer's decision on a DIP/MNRL lookup that came back flagged or unverifiable.
* Until PROCEEDED is stored, the client-create gate refuses to save the number.
*
* @param decision 'PROCEEDED' | 'REJECTED'
*/
recordDipDecision(logId: number, decision: string, reason: string): Observable<any> {
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
}
/**
* Updates existing KYC details for a client.
* @param clientId Client ID
@@ -214,6 +214,8 @@ export class CreateClientComponent {
if (idx === 0) {
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
if (this.clientGeneralStep.sanctionsBlocked) return false;
// A number the officer rejected on the DIP result must be replaced, not carried forward.
if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false;
if (!this.clientGeneralForm.valid) return false;
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
@@ -273,7 +275,17 @@ export class CreateClientComponent {
if (this.currentStepValid) return '';
const idx = this.selectedTabIndex;
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
return 'Client failed sanctions screening — cannot proceed.';
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.';
}
if (disposition === 'CONFIRMED_MATCH') {
return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.';
}
return 'Sanctions match found — review the match and record an outcome before proceeding.';
}
if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) {
return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.';
}
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
@@ -381,7 +381,14 @@ export class EditClientComponent {
if (this.savingClient) return 'Saving — please wait…';
if (this.currentStepValid) return '';
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
return 'Client failed sanctions screening — cannot proceed.';
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.';
}
if (disposition === 'CONFIRMED_MATCH') {
return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.';
}
return 'Sanctions match found — review the match and record an outcome before proceeding.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
+10
View File
@@ -607,6 +607,16 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
@@ -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', {});
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260811',
'hash': 'f379b241'
'version': '260814',
'hash': '997e8920'
},
'allow_switching_backend_instance': true
};