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>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="verify-inline">
|
<div class="verify-inline">
|
||||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -173,27 +173,27 @@
|
|||||||
(click)="verifySanctions()"
|
(click)="verifySanctions()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||||
Verify Entity
|
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (sanctionsVerified) {
|
@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>
|
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
@if (sanctionsBlocked) {
|
@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">
|
<span class="verify-status blocked">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||||
</span>
|
</span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-icon blocked-info"
|
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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
<span class="full-name-value">{{ fullNamePreview }}</span>
|
<span class="full-name-value">{{ fullNamePreview }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="verify-inline">
|
<div class="verify-inline">
|
||||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -300,27 +300,27 @@
|
|||||||
(click)="verifySanctions()"
|
(click)="verifySanctions()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
|
||||||
Verify Client
|
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (sanctionsVerified) {
|
@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>
|
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
@if (sanctionsBlocked) {
|
@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">
|
<span class="verify-status blocked">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
|
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
|
||||||
</span>
|
</span>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="sanctions-info-icon blocked-info"
|
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"
|
matTooltipClass="sanctions-tooltip"
|
||||||
>info</mat-icon
|
>info</mat-icon
|
||||||
>
|
>
|
||||||
|
|||||||
+87
-10
@@ -334,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
alternateDipLookupLogId: number | null = null;
|
alternateDipLookupLogId: number | null = null;
|
||||||
/** Alternate DIP lookup timestamp from API */
|
/** Alternate DIP lookup timestamp from API */
|
||||||
alternateDipTimestamp: string | null = null;
|
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
|
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
||||||
@@ -530,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
sanctionsVerified = false;
|
sanctionsVerified = false;
|
||||||
/** Sanctions match was found — blocks account opening */
|
/** Sanctions match was found — blocks account opening */
|
||||||
sanctionsBlocked = false;
|
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 */
|
/** Screening log ID from sanctions API */
|
||||||
screeningLogId: number | null = null;
|
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 */
|
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||||
private verifiedName: string = '';
|
private verifiedName: string = '';
|
||||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
/** 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.mobileVerified = false;
|
||||||
this.dipLookupLogId = null;
|
this.dipLookupLogId = null;
|
||||||
this.dipTimestamp = null;
|
this.dipTimestamp = null;
|
||||||
|
this.dipStatus = null;
|
||||||
|
this.dipRejected = false;
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
this.alternateDipLookupLogId = null;
|
this.alternateDipLookupLogId = null;
|
||||||
this.alternateDipTimestamp = null;
|
this.alternateDipTimestamp = null;
|
||||||
|
this.alternateDipStatus = null;
|
||||||
|
this.alternateDipRejected = false;
|
||||||
this.mobileNewOtpId = null;
|
this.mobileNewOtpId = null;
|
||||||
this.emailNewOtpId = null;
|
this.emailNewOtpId = null;
|
||||||
this.sanctionsVerified = false;
|
this.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = false;
|
this.sanctionsBlocked = false;
|
||||||
|
this.sanctionsDisposition = null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.screeningLogId = null;
|
this.screeningLogId = null;
|
||||||
this.verifiedName = '';
|
this.verifiedName = '';
|
||||||
}
|
}
|
||||||
@@ -1268,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.mobileVerified = false;
|
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
|
// 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
|
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
||||||
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
// 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$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
|
this.alternateDipRejected = false;
|
||||||
|
this.alternateDipStatus = null;
|
||||||
|
this.alternateDipLookupLogId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset create-time contact-OTP verification when email address changes — see the
|
// 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) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (result.verified) {
|
// A flagged number the officer chose to proceed with counts as verified — the decision and
|
||||||
this.mobileVerified = true;
|
// 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.dipLookupLogId = result.lookupLogId || null;
|
||||||
this.dipTimestamp = result.timestamp || null;
|
this.dipTimestamp = result.timestamp || null;
|
||||||
});
|
});
|
||||||
@@ -1342,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (result.verified) {
|
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
|
||||||
this.alternateMobileVerified = true;
|
this.alternateDipRejected = result.decision === 'REJECTED';
|
||||||
}
|
this.alternateDipStatus = result.status || null;
|
||||||
this.alternateDipLookupLogId = result.lookupLogId || null;
|
this.alternateDipLookupLogId = result.lookupLogId || null;
|
||||||
this.alternateDipTimestamp = result.timestamp || null;
|
this.alternateDipTimestamp = result.timestamp || null;
|
||||||
});
|
});
|
||||||
@@ -1387,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
*/
|
*/
|
||||||
verifySanctions() {
|
verifySanctions() {
|
||||||
const name = this.currentNameSnapshot();
|
const name = this.currentNameSnapshot();
|
||||||
|
const dob = this.createClientForm.get('dateOfBirth')?.value;
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
|
||||||
width: '550px',
|
width: '620px',
|
||||||
data: { name }
|
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) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
@@ -1399,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// CLEAR — no match found
|
// CLEAR — no match found
|
||||||
this.sanctionsVerified = true;
|
this.sanctionsVerified = true;
|
||||||
this.sanctionsBlocked = false;
|
this.sanctionsBlocked = false;
|
||||||
|
this.sanctionsDisposition = null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.verifiedName = name;
|
this.verifiedName = name;
|
||||||
} else if (result.verified === false) {
|
} 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.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = true;
|
this.sanctionsBlocked = true;
|
||||||
|
this.sanctionsDisposition = result.disposition || null;
|
||||||
|
this.sanctionsNeedsRescreen = false;
|
||||||
this.verifiedName = '';
|
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) */
|
/** Concatenated current name from the form (handles individual + entity) */
|
||||||
private currentNameSnapshot(): string {
|
private currentNameSnapshot(): string {
|
||||||
const firstname = this.createClientForm.get('firstname')?.value || '';
|
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
|
// Only invalidate if a verification was previously recorded
|
||||||
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
|
||||||
if (this.currentNameSnapshot() !== this.verifiedName) {
|
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.sanctionsVerified = false;
|
||||||
this.sanctionsBlocked = false;
|
|
||||||
this.screeningLogId = null;
|
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 { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
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>
|
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
|
||||||
</div>
|
</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) {
|
@if (!verifying && serviceUnavailable) {
|
||||||
<div class="verify-result">
|
<div class="verify-result unavailable">
|
||||||
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
|
||||||
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
|
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
|
||||||
<div class="success-detail">
|
<div class="unavailable-detail">
|
||||||
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
|
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
|
||||||
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
|
the service is reachable, or proceed with a recorded reason.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<p class="result-msg error-msg">{{ error }}</p>
|
<p class="result-msg error-msg">{{ error }}</p>
|
||||||
</div>
|
</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-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
@if (!verifying) {
|
@if (!verifying && !needsDecision) {
|
||||||
<button mat-raised-button color="primary" (click)="close()">OK</button>
|
<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>
|
</mat-dialog-actions>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
color: #d32f2f;
|
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 {
|
mat-dialog-actions {
|
||||||
padding: 8px 24px 16px;
|
padding: 8px 24px 16px;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
suspectedStatus = '';
|
suspectedStatus = '';
|
||||||
suspectedReason = '';
|
suspectedReason = '';
|
||||||
suspectedReportedBy = '';
|
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 subscription: any;
|
||||||
private intervalId: any;
|
private intervalId: any;
|
||||||
private apiResponse: any = null;
|
private apiResponse: any = null;
|
||||||
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = this.apiResponse;
|
const res = this.apiResponse;
|
||||||
|
this.priorRejection = res?.priorRejection || null;
|
||||||
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
|
||||||
this.serviceUnavailable = true;
|
this.serviceUnavailable = true;
|
||||||
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
|
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() {
|
close() {
|
||||||
const verified = !this.suspected && !this.error;
|
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
|
||||||
this.dialogRef.close({
|
this.dialogRef.close({
|
||||||
verified,
|
verified,
|
||||||
lookupLogId: this.apiResponse?.lookupLogId || null,
|
lookupLogId: this.apiResponse?.lookupLogId || null,
|
||||||
|
status: this.apiResponse?.data?.status || null,
|
||||||
timestamp: this.apiResponse?.timestamp || 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 { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
|
|
||||||
@@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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">
|
<div class="escalate-banner">
|
||||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||||
</div>
|
</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>
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!verifying && matchFound) {
|
@if (!verifying && matchFound) {
|
||||||
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
|
@if (!showReasonForm) {
|
||||||
<fa-icon icon="times"></fa-icon> Flag & Escalate
|
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
|
||||||
</button>
|
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) {
|
@if (!verifying && errorOccurred) {
|
||||||
<button mat-raised-button (click)="close(false)">Close</button>
|
<button mat-raised-button (click)="close(false)">Close</button>
|
||||||
@@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
mat-dialog-content {
|
mat-dialog-content {
|
||||||
padding: 16px 24px;
|
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 {
|
.progress-section {
|
||||||
@@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
.match-list {
|
.match-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: unset;
|
|
||||||
overflow: visible;
|
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
margin-top: 4px;
|
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 {
|
.block-msg {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
|
|
||||||
mat-dialog-actions {
|
mat-dialog-actions {
|
||||||
padding: 8px 24px 16px;
|
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: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
@@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
})
|
})
|
||||||
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||||
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
|
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);
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
clientName = '';
|
clientName = '';
|
||||||
|
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
|
||||||
|
applicantDob = '';
|
||||||
|
applicantNationality = '';
|
||||||
verifying = true;
|
verifying = true;
|
||||||
matchFound = false;
|
matchFound = false;
|
||||||
matchCount = 0;
|
matchCount = 0;
|
||||||
@@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
listDataAsOf: string | 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
|
* 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
|
* 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() {
|
ngOnInit() {
|
||||||
this.clientName = this.data?.name || '';
|
this.clientName = this.data?.name || '';
|
||||||
|
this.applicantDob = this.data?.dateOfBirth || '';
|
||||||
|
this.applicantNationality = this.data?.nationality || '';
|
||||||
this.dialogRef.disableClose = true;
|
this.dialogRef.disableClose = true;
|
||||||
|
|
||||||
// Start API call immediately
|
// Start API call immediately
|
||||||
@@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
clearInterval(this.progressTimer);
|
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) {
|
close(verified: boolean) {
|
||||||
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -855,6 +855,30 @@ export class ClientsService {
|
|||||||
return this.http.post('/sanctions/screen', { name });
|
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.
|
* Updates existing KYC details for a client.
|
||||||
* @param clientId Client ID
|
* @param clientId Client ID
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export class CreateClientComponent {
|
|||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) 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;
|
if (!this.clientGeneralForm.valid) return false;
|
||||||
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
|
// 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.
|
// 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 '';
|
if (this.currentStepValid) return '';
|
||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
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) {
|
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
|
||||||
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
|
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.savingClient) return 'Saving — please wait…';
|
||||||
if (this.currentStepValid) return '';
|
if (this.currentStepValid) return '';
|
||||||
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
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) {
|
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.';
|
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">
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
</button>
|
</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">
|
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||||
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||||
</button>
|
</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' },
|
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
|
||||||
component: SanctionsLogsComponent
|
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
|
// Must sit above ':filter', which would otherwise match this path and
|
||||||
// render an empty report category instead.
|
// render an empty report category instead.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
<h2 class="rpt-header__title">{{ 'labels.menus.Reports' | translate }}</h2>
|
<h2 class="rpt-header__title">{{ 'labels.menus.Reports' | translate }}</h2>
|
||||||
<span class="rpt-header__badge">{{ reportsData?.length || 0 }} reports</span>
|
<span class="rpt-header__badge">{{ reportsData?.length || 0 }} reports</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ===== LEVEL 0: All Categories ===== -->
|
<!-- ===== LEVEL 0: All Categories ===== -->
|
||||||
|
|||||||
@@ -38,6 +38,29 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
|
|||||||
margin: 0;
|
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 {
|
&__badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: $mono;
|
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
|
<fa-icon icon="exclamation-triangle" class="chip-icon"></fa-icon> Flagged
|
||||||
</button>
|
</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()">
|
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||||
<fa-icon icon="times"></fa-icon>
|
<fa-icon icon="times"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
@@ -72,6 +104,17 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</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">
|
<ng-container matColumnDef="matchCount">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Matches</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Matches</th>
|
||||||
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
||||||
@@ -84,7 +127,19 @@
|
|||||||
|
|
||||||
<ng-container matColumnDef="screenedAt">
|
<ng-container matColumnDef="screenedAt">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened At</th>
|
<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>
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<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 {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -66,6 +74,22 @@
|
|||||||
background-color: #d32f2f;
|
background-color: #d32f2f;
|
||||||
border-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 {
|
.result-badge {
|
||||||
@@ -89,6 +113,25 @@
|
|||||||
color: #c62828;
|
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 {
|
.select-row {
|
||||||
cursor: pointer;
|
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 { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||||
@@ -17,10 +25,13 @@ import {
|
|||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
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 { NgClass, DatePipe } from '@angular/common';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { SanctionsLogsService } from './sanctions-logs.service';
|
import { SanctionsLogsService } from './sanctions-logs.service';
|
||||||
|
import { SanctionsAlertReviewDialogComponent } from './sanctions-alert-review-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-sanctions-logs',
|
selector: 'mifosx-sanctions-logs',
|
||||||
@@ -51,19 +62,29 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
|||||||
})
|
})
|
||||||
export class SanctionsLogsComponent implements OnInit {
|
export class SanctionsLogsComponent implements OnInit {
|
||||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'screenedName',
|
'screenedName',
|
||||||
'screeningResult',
|
'screeningResult',
|
||||||
|
'disposition',
|
||||||
'matchCount',
|
'matchCount',
|
||||||
'screenedBy',
|
'screenedBy',
|
||||||
'screenedAt'
|
'screenedAt',
|
||||||
|
'actions'
|
||||||
];
|
];
|
||||||
|
|
||||||
dataSource = new MatTableDataSource<any>();
|
dataSource = new MatTableDataSource<any>();
|
||||||
searchName = '';
|
searchName = '';
|
||||||
activeFilter: string | null = null;
|
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;
|
loading = false;
|
||||||
|
rescreening = false;
|
||||||
|
|
||||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||||
@@ -74,6 +95,20 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
|
|
||||||
loadLogs() {
|
loadLogs() {
|
||||||
this.loading = true;
|
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 = {};
|
const filters: any = {};
|
||||||
if (this.activeFilter) {
|
if (this.activeFilter) {
|
||||||
filters.result = this.activeFilter;
|
filters.result = this.activeFilter;
|
||||||
@@ -81,35 +116,102 @@ export class SanctionsLogsComponent implements OnInit {
|
|||||||
if (this.searchName?.trim()) {
|
if (this.searchName?.trim()) {
|
||||||
filters.name = this.searchName.trim();
|
filters.name = this.searchName.trim();
|
||||||
}
|
}
|
||||||
this.sanctionsLogsService.getLogs(filters).subscribe({
|
return filters;
|
||||||
next: (data: any) => {
|
}
|
||||||
this.dataSource = new MatTableDataSource(data || []);
|
|
||||||
this.dataSource.paginator = this.paginator;
|
private applyRows(rows: any[]) {
|
||||||
this.dataSource.sort = this.sort;
|
this.dataSource = new MatTableDataSource(rows);
|
||||||
this.loading = false;
|
this.dataSource.paginator = this.paginator;
|
||||||
},
|
this.dataSource.sort = this.sort;
|
||||||
error: () => {
|
this.loading = false;
|
||||||
this.dataSource = new MatTableDataSource([]);
|
|
||||||
this.dataSource.paginator = this.paginator;
|
|
||||||
this.dataSource.sort = this.sort;
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applySearch(value: string) {
|
applySearch(value: string) {
|
||||||
this.searchName = value;
|
this.searchName = value;
|
||||||
|
// Name search only exists on the log endpoint, so leave the alert queue when searching.
|
||||||
|
this.alertFilter = null;
|
||||||
this.loadLogs();
|
this.loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
filterByResult(result: string | null) {
|
filterByResult(result: string | null) {
|
||||||
this.activeFilter = this.activeFilter === result ? null : result;
|
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();
|
this.loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.searchName = '';
|
this.searchName = '';
|
||||||
this.activeFilter = null;
|
this.activeFilter = null;
|
||||||
|
this.alertFilter = null;
|
||||||
this.loadLogs();
|
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 { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
@@ -16,4 +24,39 @@ export class SanctionsLogsService {
|
|||||||
}
|
}
|
||||||
return this.http.get('/sanctions/logs', { params });
|
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 */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260811',
|
'version': '260814',
|
||||||
'hash': 'f379b241'
|
'hash': '997e8920'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user