feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports. - Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons. - Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars. - Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval. - Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names. - Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks. - Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
This commit is contained in:
+14
-14
@@ -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 & 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 & 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 & 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 & 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
|
||||
>
|
||||
|
||||
+87
-10
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
+248
-8
@@ -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 || '—' }} · Nationality: {{ m.nationality || '—' }} · 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 & 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 & 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 });
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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 & 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 & 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.
|
||||
{
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
<h2 class="rpt-header__title">{{ 'labels.menus.Reports' | translate }}</h2>
|
||||
<span class="rpt-header__badge">{{ reportsData?.length || 0 }} reports</span>
|
||||
</div>
|
||||
<!--
|
||||
Sanctions alerts are not a runnable report, so they never appear in the category tiles
|
||||
below — yet this is where the Principal Officer has to come to clear a flagged screening.
|
||||
Without this link the review queue was reachable only by typing the URL.
|
||||
-->
|
||||
<a class="rpt-header__action" routerLink="/reports/sanctions-logs">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
Sanctions Screening & Alerts
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== LEVEL 0: All Categories ===== -->
|
||||
|
||||
@@ -38,6 +38,29 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__action {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 20px;
|
||||
padding: 7px 16px;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
font-size: 11px;
|
||||
font-family: $mono;
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
|
||||
/**
|
||||
* Principal Officer review of a UNSC sanctions alert.
|
||||
*
|
||||
* This is the checker half of the flow: the branch officer records whether the hit is the same
|
||||
* person, and nothing takes effect until it is approved here. Approving a false positive clears
|
||||
* the name for onboarding; approving a confirmed match freezes the client's accounts and opens
|
||||
* the FIU-IND / nodal officer reporting fields for the 24-hour obligation.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-sanctions-alert-review-dialog',
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
<fa-icon icon="shield-alt" class="title-icon"></fa-icon>
|
||||
Sanctions Alert #{{ alert.id }}
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Screened Name</span>
|
||||
<span class="detail-value strong">{{ alert.screenedName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Matched On List</span>
|
||||
<span class="detail-value">{{ alert.matchedNames || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Matches</span>
|
||||
<span class="detail-value">{{ alert.matchCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Source</span>
|
||||
<span class="detail-value">{{ alert.screeningSource || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Client</span>
|
||||
<span class="detail-value">{{ alert.clientId || 'Not yet onboarded' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Screened On</span>
|
||||
<span class="detail-value">{{ alert.screenedOn | date: 'medium' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Reviewing Officer's Assessment</div>
|
||||
@if (alert.disposition === 'PENDING_REVIEW') {
|
||||
<div class="pending-note">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
Not yet reviewed. The branch officer must record an outcome before this can be approved.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
|
||||
<div class="assessment-badge">{{ dispositionLabel }}</div>
|
||||
<p class="assessment-reason">{{ alert.dispositionReason || 'No reason recorded.' }}</p>
|
||||
<div class="assessment-meta">
|
||||
Reviewed by user {{ alert.reviewedBy || '—' }} on {{ alert.reviewedOn | date: 'medium' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alert.approvedBy) {
|
||||
<div class="approved-note">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Approved by user {{ alert.approvedBy }} on {{ alert.approvedOn | date: 'medium' }}
|
||||
@if (alert.frozenAccountIds) {
|
||||
<span> · Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
|
||||
<div class="section-title">FIU-IND / Nodal Officer Reporting</div>
|
||||
<div class="report-form">
|
||||
<label class="field-label">FIU-IND STR reference</label>
|
||||
<input class="field-input" [(ngModel)]="fiuStrReference" placeholder="e.g. STR/2026/000412" />
|
||||
<label class="field-label">Nodal officer informed on</label>
|
||||
<input class="field-input" type="date" [(ngModel)]="nodalOfficerInformedOn" />
|
||||
<div class="field-hint">
|
||||
Section 51-A UAPA requires FIU-IND and the nodal officer to be informed within 24 hours of confirming the
|
||||
match.
|
||||
</div>
|
||||
<button mat-stroked-button (click)="saveReporting()" [disabled]="saving" class="report-btn">
|
||||
{{ saving ? 'Saving…' : 'Save Reporting Details' }}
|
||||
</button>
|
||||
@if (savedMessage) {
|
||||
<div class="saved-note">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
{{ savedMessage }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="error-note">{{ errorMessage }}</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
|
||||
@if (canApprove) {
|
||||
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
{{ saving ? 'Approving…' : approveLabel }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
h2[mat-dialog-title] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 16px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-value.strong {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
|
||||
.pending-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.assessment-card {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1074b9;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.assessment-card.assessment-confirmed {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
|
||||
.assessment-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
color: #1074b9;
|
||||
}
|
||||
|
||||
.assessment-confirmed .assessment-badge {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.assessment-reason {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin: 6px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.assessment-meta {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.approved-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.report-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: #1074b9;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.saved-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 8px 24px 16px;
|
||||
}
|
||||
`
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class SanctionsAlertReviewDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<SanctionsAlertReviewDialogComponent>);
|
||||
private service = inject(SanctionsLogsService);
|
||||
|
||||
alert: any = inject(MAT_DIALOG_DATA);
|
||||
|
||||
fiuStrReference = '';
|
||||
nodalOfficerInformedOn = '';
|
||||
saving = false;
|
||||
errorMessage = '';
|
||||
savedMessage = '';
|
||||
/** Set when anything was persisted, so the caller knows to refresh the table. */
|
||||
private changed = false;
|
||||
|
||||
constructor() {
|
||||
this.fiuStrReference = this.alert?.fiuStrReference || '';
|
||||
this.nodalOfficerInformedOn = this.alert?.nodalOfficerInformedOn
|
||||
? String(this.alert.nodalOfficerInformedOn).substring(0, 10)
|
||||
: '';
|
||||
}
|
||||
|
||||
get dispositionLabel(): string {
|
||||
return this.alert?.disposition === 'FALSE_POSITIVE' ? 'NOT THE SAME PERSON' : 'CONFIRMED MATCH';
|
||||
}
|
||||
|
||||
/** Only a reviewed, not-yet-approved alert is actionable here. */
|
||||
get canApprove(): boolean {
|
||||
return this.alert?.disposition && this.alert.disposition !== 'PENDING_REVIEW' && !this.alert.approvedBy;
|
||||
}
|
||||
|
||||
get approveLabel(): string {
|
||||
return this.alert?.disposition === 'FALSE_POSITIVE'
|
||||
? 'Approve — Clear for Onboarding'
|
||||
: 'Approve — Confirm & Freeze';
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
this.savedMessage = '';
|
||||
this.service.approveAlert(this.alert.id).subscribe({
|
||||
next: (updated: any) => {
|
||||
this.saving = false;
|
||||
this.changed = true;
|
||||
this.alert = updated || this.alert;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.errorMessage = this.extractMessage(err, 'Could not approve this alert.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveReporting() {
|
||||
this.errorMessage = '';
|
||||
this.savedMessage = '';
|
||||
|
||||
const strReference = this.fiuStrReference.trim();
|
||||
if (strReference.length < 4) {
|
||||
this.errorMessage = 'Enter the acknowledgement reference FIU-IND returned for the STR.';
|
||||
return;
|
||||
}
|
||||
if (!this.nodalOfficerInformedOn) {
|
||||
this.errorMessage = 'Enter the date the nodal officer was informed.';
|
||||
return;
|
||||
}
|
||||
// The intimation cannot predate the screening that triggered it — a backdated entry here is
|
||||
// exactly the kind of record a regulator would treat as fabricated.
|
||||
const screenedOn = this.alert?.screenedOn ? new Date(this.alert.screenedOn) : null;
|
||||
if (screenedOn && !isNaN(screenedOn.getTime())) {
|
||||
const screenedDay = new Date(screenedOn.getFullYear(), screenedOn.getMonth(), screenedOn.getDate());
|
||||
if (new Date(this.nodalOfficerInformedOn) < screenedDay) {
|
||||
this.errorMessage = 'The nodal officer cannot have been informed before the match was screened.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.service.recordReporting(this.alert.id, strReference, this.nodalOfficerInformedOn).subscribe({
|
||||
next: (updated: any) => {
|
||||
this.saving = false;
|
||||
this.changed = true;
|
||||
this.alert = updated || this.alert;
|
||||
this.savedMessage = 'Reporting details saved against this alert.';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.errorMessage = this.extractMessage(err, 'Could not save the reporting details.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dialogRef.close(this.changed);
|
||||
}
|
||||
|
||||
private extractMessage(err: any, fallback: string): string {
|
||||
return err?.error?.defaultUserMessage || err?.error?.message || err?.message || fallback;
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,44 @@
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle" class="chip-icon"></fa-icon> Flagged
|
||||
</button>
|
||||
@if (searchName || activeFilter) {
|
||||
<!--
|
||||
A flagged screening is a case that has to be closed out, so the officer needs a queue of
|
||||
the ones still open — not just a list of everything ever screened.
|
||||
-->
|
||||
<button
|
||||
mat-stroked-button
|
||||
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
|
||||
class="chip-pending"
|
||||
(click)="filterByAlert('PENDING_REVIEW')"
|
||||
>
|
||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Pending Review
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
[class.active-filter]="alertFilter === 'ALL'"
|
||||
class="chip-flagged"
|
||||
(click)="filterByAlert('ALL')"
|
||||
>
|
||||
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
|
||||
</button>
|
||||
@if (searchName || activeFilter || alertFilter) {
|
||||
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="rescreen-btn"
|
||||
[disabled]="rescreening"
|
||||
matTooltip="Screen every existing client against the currently loaded UN list snapshot. This is what detects a customer added to the list after onboarding."
|
||||
(click)="rescreenExistingClients()"
|
||||
>
|
||||
<fa-icon icon="sync" class="chip-icon"></fa-icon>
|
||||
{{ rescreening ? 'Re-screening…' : 'Re-screen Existing Clients' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
@@ -72,6 +104,17 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="disposition">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Disposition</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
@if (isAlert(log)) {
|
||||
<span class="result-badge" [ngClass]="dispositionClass(log)">{{ dispositionLabel(log) }}</span>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="matchCount">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Matches</th>
|
||||
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
||||
@@ -84,7 +127,19 @@
|
||||
|
||||
<ng-container matColumnDef="screenedAt">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened At</th>
|
||||
<td mat-cell *matCellDef="let log">{{ log.screenedAt | date: 'medium' }}</td>
|
||||
<!-- The API field is screenedOn; the old screenedAt binding rendered an empty column. -->
|
||||
<td mat-cell *matCellDef="let log">{{ log.screenedOn | date: 'medium' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Review</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
@if (isAlert(log)) {
|
||||
<button mat-stroked-button color="primary" class="review-btn" (click)="reviewAlert(log)">
|
||||
<fa-icon icon="search"></fa-icon> Review
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -66,6 +74,22 @@
|
||||
background-color: #d32f2f;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
|
||||
.chip-pending.active-filter {
|
||||
background-color: #b45309;
|
||||
border-color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
.rescreen-btn {
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
margin-left: auto;
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
@@ -89,6 +113,25 @@
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* Reviewed but not yet signed off by the Principal Officer — still blocking. */
|
||||
.badge-pending {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-row {
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
@@ -17,10 +25,13 @@ import {
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NgClass, DatePipe } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
import { SanctionsAlertReviewDialogComponent } from './sanctions-alert-review-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-sanctions-logs',
|
||||
@@ -51,19 +62,29 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
})
|
||||
export class SanctionsLogsComponent implements OnInit {
|
||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'screenedName',
|
||||
'screeningResult',
|
||||
'disposition',
|
||||
'matchCount',
|
||||
'screenedBy',
|
||||
'screenedAt'
|
||||
'screenedAt',
|
||||
'actions'
|
||||
];
|
||||
|
||||
dataSource = new MatTableDataSource<any>();
|
||||
searchName = '';
|
||||
activeFilter: string | null = null;
|
||||
/**
|
||||
* When set, the table shows the Principal Officer's alert queue (flagged screenings and their
|
||||
* disposition) instead of the raw screening log. 'ALL' means every alert regardless of state.
|
||||
*/
|
||||
alertFilter: string | null = null;
|
||||
loading = false;
|
||||
rescreening = false;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
@@ -74,6 +95,20 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
|
||||
loadLogs() {
|
||||
this.loading = true;
|
||||
|
||||
// The alert queue is a different endpoint, not a filter on the log list: it carries the
|
||||
// disposition columns the review dialog needs.
|
||||
const request$ = this.alertFilter
|
||||
? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter)
|
||||
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
|
||||
|
||||
request$.subscribe({
|
||||
next: (data: any) => this.applyRows(data || []),
|
||||
error: () => this.applyRows([])
|
||||
});
|
||||
}
|
||||
|
||||
private buildLogFilters() {
|
||||
const filters: any = {};
|
||||
if (this.activeFilter) {
|
||||
filters.result = this.activeFilter;
|
||||
@@ -81,35 +116,102 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
if (this.searchName?.trim()) {
|
||||
filters.name = this.searchName.trim();
|
||||
}
|
||||
this.sanctionsLogsService.getLogs(filters).subscribe({
|
||||
next: (data: any) => {
|
||||
this.dataSource = new MatTableDataSource(data || []);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.dataSource = new MatTableDataSource([]);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
|
||||
private applyRows(rows: any[]) {
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
applySearch(value: string) {
|
||||
this.searchName = value;
|
||||
// Name search only exists on the log endpoint, so leave the alert queue when searching.
|
||||
this.alertFilter = null;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
filterByResult(result: string | null) {
|
||||
this.activeFilter = this.activeFilter === result ? null : result;
|
||||
this.alertFilter = null;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
filterByAlert(status: string) {
|
||||
this.alertFilter = this.alertFilter === status ? null : status;
|
||||
this.activeFilter = null;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.searchName = '';
|
||||
this.activeFilter = null;
|
||||
this.alertFilter = null;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
/** Only flagged rows carry a case to review. */
|
||||
isAlert(row: any): boolean {
|
||||
return !!row?.disposition;
|
||||
}
|
||||
|
||||
dispositionLabel(row: any): string {
|
||||
switch (row?.disposition) {
|
||||
case 'PENDING_REVIEW':
|
||||
return 'Pending Review';
|
||||
case 'FALSE_POSITIVE':
|
||||
return row.approvedBy ? 'False Positive — Approved' : 'False Positive — Awaiting PO';
|
||||
case 'CONFIRMED_MATCH':
|
||||
if (!row.approvedBy) {
|
||||
return 'Confirmed — Awaiting PO';
|
||||
}
|
||||
// An onboarding match has no client and therefore no accounts to freeze — saying
|
||||
// "Frozen" there would claim an action that never happened.
|
||||
return row.frozenAccountIds ? 'Confirmed — Frozen' : 'Confirmed — Blocked';
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
dispositionClass(row: any): string {
|
||||
if (row?.disposition === 'CONFIRMED_MATCH') return 'badge-flagged';
|
||||
if (row?.disposition === 'FALSE_POSITIVE') return row.approvedBy ? 'badge-clear' : 'badge-pending';
|
||||
if (row?.disposition === 'PENDING_REVIEW') return 'badge-pending';
|
||||
return '';
|
||||
}
|
||||
|
||||
reviewAlert(row: any) {
|
||||
this.dialog
|
||||
.open(SanctionsAlertReviewDialogComponent, { width: '600px', data: { ...row }, autoFocus: false })
|
||||
.afterClosed()
|
||||
.subscribe((changed: boolean) => {
|
||||
if (changed) {
|
||||
this.loadLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Screens every existing client against the currently loaded list snapshot. This is what
|
||||
* detects a customer who was added to the UN list after they were onboarded — the only
|
||||
* situation in which there are accounts to freeze.
|
||||
*/
|
||||
rescreenExistingClients() {
|
||||
this.rescreening = true;
|
||||
this.sanctionsLogsService.rescreenExistingClients().subscribe({
|
||||
next: (res: any) => {
|
||||
this.rescreening = false;
|
||||
this.snackBar.open(`Re-screening complete — ${res?.clientsFlagged ?? 0} client(s) flagged.`, 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
this.filterByAlert('PENDING_REVIEW');
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rescreening = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
@@ -16,4 +24,39 @@ export class SanctionsLogsService {
|
||||
}
|
||||
return this.http.get('/sanctions/logs', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flagged screenings and their disposition state — the Principal Officer's review queue.
|
||||
* @param status PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH, or omitted for all alerts
|
||||
*/
|
||||
getAlerts(status?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
return this.http.get('/sanctions/alerts', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal Officer sign-off. Approving a false positive clears the name for onboarding;
|
||||
* approving a confirmed match freezes debits on the client's active deposit accounts.
|
||||
*/
|
||||
approveAlert(logId: number): Observable<any> {
|
||||
return this.http.post(`/sanctions/alerts/${logId}/approve`, {});
|
||||
}
|
||||
|
||||
/** Sends the alert back for a fresh assessment by overwriting its disposition. */
|
||||
recordDisposition(logId: number, disposition: string, reason: string): Observable<any> {
|
||||
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
|
||||
}
|
||||
|
||||
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
|
||||
recordReporting(logId: number, fiuStrReference: string, nodalOfficerInformedOn: string): Observable<any> {
|
||||
return this.http.post(`/sanctions/alerts/${logId}/report`, { fiuStrReference, nodalOfficerInformedOn });
|
||||
}
|
||||
|
||||
/** Re-screens every existing client against the currently loaded list snapshot. */
|
||||
rescreenExistingClients(): Observable<any> {
|
||||
return this.http.post('/sanctions/rescreen', {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260811',
|
||||
'hash': 'f379b241'
|
||||
'version': '260814',
|
||||
'hash': '997e8920'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user