diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index 463d99599..6fbf39851 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -163,7 +163,7 @@
}}
- @if (!sanctionsVerified && !sanctionsBlocked) {
+ @if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
- Verify Entity
+ {{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
}
@if (sanctionsVerified) {
- RBI KYC Master Direction (2016) — Section 51 & PMLA 2002
+ {{ sanctionsCitation }}
Verified
info
}
@if (sanctionsBlocked) {
- RBI KYC Master Direction (2016) — Section 51 & PMLA 2002
+ {{ sanctionsCitation }}
- Sanctions Match
+ {{ sanctionsStatusLabel }}
info
@@ -290,7 +290,7 @@
{{ fullNamePreview }}
- @if (!sanctionsVerified && !sanctionsBlocked) {
+ @if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
- Verify Client
+ {{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
}
@if (sanctionsVerified) {
- RBI KYC Master Direction (2016) — Section 51 & PMLA 2002
+ {{ sanctionsCitation }}
Verified
info
}
@if (sanctionsBlocked) {
- RBI KYC Master Direction (2016) — Section 51 & PMLA 2002
+ {{ sanctionsCitation }}
- Sanctions Match
+ {{ sanctionsStatusLabel }}
info
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index 3bd5bc706..910651bb5 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -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;
+ }
}
});
}
diff --git a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts
index d7a857761..9ac055f0c 100644
--- a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts
@@ -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';
This may take a few seconds. Do not close or refresh this window.
}
+
@if (!verifying && serviceUnavailable) {
-
-
-
DIP & MNRL verification completed successfully.
-
-
-
Mobile number is CLEAN — not found in the revocation list.
+
+
+
Could not verify — DIP/MNRL service unavailable
+
+ This number has not been checked against the DoT Digital Intelligence Platform. Retry once
+ the service is reachable, or proceed with a recorded reason.
}
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
{{ error }}
}
+
+
+ @if (!verifying && priorRejection) {
+
+
+
+
This number was rejected before.
+
+ Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
+ status was {{ priorRejection.lookupStatus }}.
+
+
+
+ }
+
+ @if (!verifying && needsDecision && showReasonForm) {
+
+ }
- @if (!verifying) {
+ @if (!verifying && !needsDecision) {
OK
}
+
+ @if (!verifying && needsDecision && !showReasonForm) {
+ Reject this Number
+
+ Proceed with Reason
+
+ }
+ @if (!verifying && needsDecision && showReasonForm) {
+ Back
+
+ {{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
+
+ }
`,
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
});
}
diff --git a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts
index 540dc0c20..752a3654e 100644
--- a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts
@@ -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';
}
+
+
+
+
Applicant
+
+ {{ clientName }}
+ DOB: {{ applicantDob || '—' }}
+ Nationality: {{ applicantNationality || '—' }}
+
+
+
+ @for (m of matches; track m.id) {
+
+
{{ m.fullName }}
+
+ {{ m.entryType }}
+ @if (m.unListType) {
+ {{ m.unListType }}
+ }
+ @if (m.referenceNumber) {
+ {{ m.referenceNumber }}
+ }
+
+
+ DOB: {{ m.dateOfBirth || '—' }} · Nationality: {{ m.nationality || '—' }} · Place of
+ birth: {{ m.placeOfBirth || '—' }}
+
+ @if (m.aliases?.length) {
+
Aliases: {{ m.aliases.join('; ') }}
+ }
+ @if (m.listedOn) {
+
Listed on: {{ m.listedOn }}
+ }
+
+ }
+
Escalate to Principal Officer immediately. Account opening blocked.
+ @if (showReasonForm) {
+
+ }
{{ regulatoryBasis }}
}
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
}
@if (!verifying && matchFound) {
-
- Flag & Escalate
-
+ @if (!showReasonForm) {
+
+ Not the Same Person
+
+
+ Confirm Match & Escalate
+
+ }
+ @if (showReasonForm) {
+ Back
+
+ {{ savingDisposition ? 'Saving…' : 'Submit for Principal Officer Approval' }}
+
+ }
}
@if (!verifying && errorOccurred) {
Close
@@ -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);
- 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 });
}
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index b84bf8709..2145f060a 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -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 {
+ 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 {
+ return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
+ }
+
/**
* Updates existing KYC details for a client.
* @param clientId Client ID
diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts
index 8fcde0678..6218ea5ac 100644
--- a/src/app/clients/create-client/create-client.component.ts
+++ b/src/app/clients/create-client/create-client.component.ts
@@ -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.';
diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts
index 3cc507649..c55beef28 100644
--- a/src/app/clients/edit-client/edit-client.component.ts
+++ b/src/app/clients/edit-client/edit-client.component.ts
@@ -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.';
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index d52c27b31..e60dd5be3 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -607,6 +607,16 @@
Risk Review Report
+
+
+ Sanctions Screening & Alerts
+
+
+ DIP / MNRL Mobile Lookups
+
Deposit Interest & TDS
diff --git a/src/app/reports/dip-lookups/dip-decision-dialog.component.ts b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts
new file mode 100644
index 000000000..408a421d2
--- /dev/null
+++ b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts
@@ -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: `
+
+
+ Mobile Lookup #{{ row.id }}
+
+
+
+
+ Mobile Number
+ {{ row.mobileNumber }}
+
+
+ Status
+ {{ row.lookupStatus }}
+
+
+ Result
+ {{ row.responseMessage || '—' }}
+
+
+ Source
+ {{ row.lookupSource || '—' }}
+
+
+ Client
+ {{ row.clientId || 'Not onboarded' }}
+
+
+ Looked Up
+ {{ row.lookedUpOn | date: 'medium' }}
+
+
+
+ @if (row.overrideDecision) {
+ Recorded Decision
+
+
+ {{ row.overrideDecision === 'PROCEEDED' ? 'PROCEEDED' : 'REJECTED' }}
+
+
{{ row.overrideReason || 'No reason recorded.' }}
+
+ Decided by user {{ row.decidedBy || '—' }} on {{ row.decidedOn | date: 'medium' }}
+
+
+ } @else {
+ Record a Decision
+
+
+ This number is flagged and has not been decided. It cannot be saved on a client until an officer proceeds with
+ reasons.
+
+ @if (showReasonForm) {
+
+ }
+ }
+
+ @if (errorMessage) {
+ {{ errorMessage }}
+ }
+
+
+ Close
+ @if (!row.overrideDecision && !showReasonForm) {
+ Reject Number
+
+ Proceed with Reason
+
+ }
+ @if (!row.overrideDecision && showReasonForm) {
+
+ {{ saving ? 'Saving…' : 'Save Decision' }}
+
+ }
+
+ `,
+ 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);
+ 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);
+ }
+}
diff --git a/src/app/reports/dip-lookups/dip-lookups.component.html b/src/app/reports/dip-lookups/dip-lookups.component.html
new file mode 100644
index 000000000..09ef230bc
--- /dev/null
+++ b/src/app/reports/dip-lookups/dip-lookups.component.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+ Search by Mobile Number
+
+
+
+
+
+
+ All
+
+
+ Clean
+
+
+ Suspected
+
+
+ Not Verified
+
+
+
+ Awaiting Decision
+
+ @if (searchNumber || activeStatus || pendingOnly) {
+
+
+
+ }
+
+
+
+ @if (loading) {
+
+ }
+
+
+
+
+ Mobile Number
+ {{ row.mobileNumber }}
+
+
+
+ Status
+
+ {{ row.lookupStatus }}
+
+
+
+
+ Decision
+
+ {{ decisionLabel(row) }}
+
+
+
+
+ Source
+ {{ row.lookupSource || '—' }}
+
+
+
+ Client
+ {{ row.clientId || 'Not onboarded' }}
+
+
+
+ Looked Up
+ {{ row.lookedUpOn | date: 'medium' }}
+
+
+
+ Review
+
+ @if (needsDecision(row) || row.overrideDecision) {
+
+ {{ needsDecision(row) ? 'Decide' : 'View' }}
+
+ }
+
+
+
+
+
+
+
+ @if (!loading && dataSource.data.length === 0) {
+
+
+
No mobile lookups found.
+
+ }
+
+
+
+
diff --git a/src/app/reports/dip-lookups/dip-lookups.component.scss b/src/app/reports/dip-lookups/dip-lookups.component.scss
new file mode 100644
index 000000000..1a8c37aba
--- /dev/null
+++ b/src/app/reports/dip-lookups/dip-lookups.component.scss
@@ -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;
+}
diff --git a/src/app/reports/dip-lookups/dip-lookups.component.ts b/src/app/reports/dip-lookups/dip-lookups.component.ts
new file mode 100644
index 000000000..d8fad5230
--- /dev/null
+++ b/src/app/reports/dip-lookups/dip-lookups.component.ts
@@ -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();
+ 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();
+ }
+ });
+ }
+}
diff --git a/src/app/reports/dip-lookups/dip-lookups.service.ts b/src/app/reports/dip-lookups/dip-lookups.service.ts
new file mode 100644
index 000000000..bf4c24668
--- /dev/null
+++ b/src/app/reports/dip-lookups/dip-lookups.service.ts
@@ -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 {
+ 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 {
+ return this.http.get('/lookup/pending');
+ }
+
+ /** @param decision 'PROCEEDED' | 'REJECTED' */
+ recordDecision(logId: number, decision: string, reason: string): Observable {
+ return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
+ }
+}
diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts
index 7565d21ec..c19074d2a 100644
--- a/src/app/reports/reports-routing.module.ts
+++ b/src/app/reports/reports-routing.module.ts
@@ -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.
{
diff --git a/src/app/reports/reports.component.html b/src/app/reports/reports.component.html
index 5025998dc..80216b9ae 100644
--- a/src/app/reports/reports.component.html
+++ b/src/app/reports/reports.component.html
@@ -14,6 +14,15 @@
+
+
diff --git a/src/app/reports/reports.component.scss b/src/app/reports/reports.component.scss
index 686fd2d24..ff2be74cc 100644
--- a/src/app/reports/reports.component.scss
+++ b/src/app/reports/reports.component.scss
@@ -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;
diff --git a/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts
new file mode 100644
index 000000000..3570653dd
--- /dev/null
+++ b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts
@@ -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: `
+
+
+ Sanctions Alert #{{ alert.id }}
+
+
+
+
+ Screened Name
+ {{ alert.screenedName }}
+
+
+ Matched On List
+ {{ alert.matchedNames || '—' }}
+
+
+ Matches
+ {{ alert.matchCount || 0 }}
+
+
+ Source
+ {{ alert.screeningSource || '—' }}
+
+
+ Client
+ {{ alert.clientId || 'Not yet onboarded' }}
+
+
+ Screened On
+ {{ alert.screenedOn | date: 'medium' }}
+
+
+
+ Reviewing Officer's Assessment
+ @if (alert.disposition === 'PENDING_REVIEW') {
+
+
+ Not yet reviewed. The branch officer must record an outcome before this can be approved.
+
+ } @else {
+
+
{{ dispositionLabel }}
+
{{ alert.dispositionReason || 'No reason recorded.' }}
+
+ Reviewed by user {{ alert.reviewedBy || '—' }} on {{ alert.reviewedOn | date: 'medium' }}
+
+
+ }
+
+ @if (alert.approvedBy) {
+
+
+ Approved by user {{ alert.approvedBy }} on {{ alert.approvedOn | date: 'medium' }}
+ @if (alert.frozenAccountIds) {
+ · Debits frozen on account(s) {{ alert.frozenAccountIds }}
+ }
+
+ }
+
+ @if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
+ FIU-IND / Nodal Officer Reporting
+
+ }
+
+ @if (errorMessage) {
+ {{ errorMessage }}
+ }
+
+
+ Close
+ @if (canApprove) {
+
+
+ {{ saving ? 'Approving…' : approveLabel }}
+
+ }
+
+ `,
+ 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);
+ 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;
+ }
+}
diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.html b/src/app/reports/sanctions-logs/sanctions-logs.component.html
index 6d90a0da8..976cf44a1 100644
--- a/src/app/reports/sanctions-logs/sanctions-logs.component.html
+++ b/src/app/reports/sanctions-logs/sanctions-logs.component.html
@@ -39,12 +39,44 @@
>
Flagged
- @if (searchName || activeFilter) {
+
+
+ Pending Review
+
+
+ All Alerts
+
+ @if (searchName || activeFilter || alertFilter) {
}
+
+
+
+ {{ rescreening ? 'Re-screening…' : 'Re-screen Existing Clients' }}
+
@if (loading) {
@@ -72,6 +104,17 @@
+
+ Disposition
+
+ @if (isAlert(log)) {
+ {{ dispositionLabel(log) }}
+ } @else {
+ —
+ }
+
+
+
Matches
{{ log.matchCount || 0 }}
@@ -84,7 +127,19 @@
Screened At
- {{ log.screenedAt | date: 'medium' }}
+
+ {{ log.screenedOn | date: 'medium' }}
+
+
+
+ Review
+
+ @if (isAlert(log)) {
+
+ Review
+
+ }
+
diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.scss b/src/app/reports/sanctions-logs/sanctions-logs.component.scss
index 05c32eddb..88ee9f692 100644
--- a/src/app/reports/sanctions-logs/sanctions-logs.component.scss
+++ b/src/app/reports/sanctions-logs/sanctions-logs.component.scss
@@ -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;
diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.ts b/src/app/reports/sanctions-logs/sanctions-logs.component.ts
index 6328fbce7..bb2531c21 100644
--- a/src/app/reports/sanctions-logs/sanctions-logs.component.ts
+++ b/src/app/reports/sanctions-logs/sanctions-logs.component.ts
@@ -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();
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 });
+ }
+ });
+ }
}
diff --git a/src/app/reports/sanctions-logs/sanctions-logs.service.ts b/src/app/reports/sanctions-logs/sanctions-logs.service.ts
index 4f16e5fdd..4419a27ea 100644
--- a/src/app/reports/sanctions-logs/sanctions-logs.service.ts
+++ b/src/app/reports/sanctions-logs/sanctions-logs.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ return this.http.post(`/sanctions/alerts/${logId}/report`, { fiuStrReference, nodalOfficerInformedOn });
+ }
+
+ /** Re-screens every existing client against the currently loaded list snapshot. */
+ rescreenExistingClients(): Observable {
+ return this.http.post('/sanctions/rescreen', {});
+ }
}
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index a08b20b4a..543316953 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '260811',
- 'hash': 'f379b241'
+ 'version': '260814',
+ 'hash': '997e8920'
},
'allow_switching_backend_instance': true
};