From 352a65cb5dc4d6504bb289b25297b04aed2da6ce Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 14 Aug 2026 10:42:23 +0530 Subject: [PATCH] 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. --- .../client-general-step.component.html | 28 +- .../client-general-step.component.ts | 97 +++- .../dip-verify-dialog.component.ts | 246 +++++++++- .../sanctions-verify-dialog.component.ts | 256 ++++++++++- src/app/clients/clients.service.ts | 24 + .../create-client/create-client.component.ts | 14 +- .../edit-client/edit-client.component.ts | 9 +- src/app/core/shell/shell.component.html | 10 + .../dip-decision-dialog.component.ts | 349 ++++++++++++++ .../dip-lookups/dip-lookups.component.html | 131 ++++++ .../dip-lookups/dip-lookups.component.scss | 152 +++++++ .../dip-lookups/dip-lookups.component.ts | 178 ++++++++ .../dip-lookups/dip-lookups.service.ts | 38 ++ src/app/reports/reports-routing.module.ts | 5 + src/app/reports/reports.component.html | 9 + src/app/reports/reports.component.scss | 23 + ...sanctions-alert-review-dialog.component.ts | 430 ++++++++++++++++++ .../sanctions-logs.component.html | 59 ++- .../sanctions-logs.component.scss | 43 ++ .../sanctions-logs.component.ts | 132 +++++- .../sanctions-logs/sanctions-logs.service.ts | 43 ++ src/environments/.env.ts | 4 +- 22 files changed, 2219 insertions(+), 61 deletions(-) create mode 100644 src/app/reports/dip-lookups/dip-decision-dialog.component.ts create mode 100644 src/app/reports/dip-lookups/dip-lookups.component.html create mode 100644 src/app/reports/dip-lookups/dip-lookups.component.scss create mode 100644 src/app/reports/dip-lookups/dip-lookups.component.ts create mode 100644 src/app/reports/dip-lookups/dip-lookups.service.ts create mode 100644 src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index 463d99599..6fbf39851 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -163,7 +163,7 @@ }}
- @if (!sanctionsVerified && !sanctionsBlocked) { + @if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) { } @if (sanctionsVerified) { - RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + {{ sanctionsCitation }} Verified info } @if (sanctionsBlocked) { - RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + {{ sanctionsCitation }} - Sanctions Match + {{ sanctionsStatusLabel }} info @@ -290,7 +290,7 @@ {{ fullNamePreview }}
- @if (!sanctionsVerified && !sanctionsBlocked) { + @if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) { } @if (sanctionsVerified) { - RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + {{ sanctionsCitation }} Verified info } @if (sanctionsBlocked) { - RBI KYC Master Direction (2016) — Section 51 & PMLA 2002 + {{ sanctionsCitation }} - Sanctions Match + {{ sanctionsStatusLabel }} info diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 3bd5bc706..910651bb5 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -334,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges alternateDipLookupLogId: number | null = null; /** Alternate DIP lookup timestamp from API */ alternateDipTimestamp: string | null = null; + /** + * DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE). + * Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable + * from one that genuinely passed. + */ + dipStatus: string | null = null; + alternateDipStatus: string | null = null; + /** Officer rejected the number outright — it must be replaced before saving. */ + dipRejected = false; + alternateDipRejected = false; /** * CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the @@ -530,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges sanctionsVerified = false; /** Sanctions match was found — blocks account opening */ sanctionsBlocked = false; + /** + * Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval) + * or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client + * blocked — only the Principal Officer's approval, applied server-side, releases a name. + */ + sanctionsDisposition: string | null = null; + /** + * The name was edited after a block, so the applicant may no longer be the flagged one. + * Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API. + */ + sanctionsNeedsRescreen = false; /** Screening log ID from sanctions API */ screeningLogId: number | null = null; + + /** Cites the direction actually in force — kept in step with the verification dialog. */ + readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX'; + readonly sanctionsTooltip = + 'All customer names must be verified against the UN Security Council Consolidated List before account ' + + 'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' + + 'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.'; + + /** Badge text reflecting how far the flagged alert has progressed. */ + get sanctionsStatusLabel(): string { + if (this.sanctionsDisposition === 'FALSE_POSITIVE') { + return 'Awaiting PO Approval'; + } + if (this.sanctionsDisposition === 'CONFIRMED_MATCH') { + return 'Confirmed Match'; + } + return 'Sanctions Match'; + } /** Snapshot of the name that was last verified — used to invalidate verification when name changes */ private verifiedName: string = ''; /** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */ @@ -665,13 +704,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.mobileVerified = false; this.dipLookupLogId = null; this.dipTimestamp = null; + this.dipStatus = null; + this.dipRejected = false; this.alternateMobileVerified = false; this.alternateDipLookupLogId = null; this.alternateDipTimestamp = null; + this.alternateDipStatus = null; + this.alternateDipRejected = false; this.mobileNewOtpId = null; this.emailNewOtpId = null; this.sanctionsVerified = false; this.sanctionsBlocked = false; + this.sanctionsDisposition = null; + this.sanctionsNeedsRescreen = false; this.screeningLogId = null; this.verifiedName = ''; } @@ -1268,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => { this.mobileVerified = false; + // The rejection applied to the previous number, not this one. Leaving it set would + // keep Save & Next dead with a message about a number no longer in the field. + this.dipRejected = false; + this.dipStatus = null; + this.dipLookupLogId = null; // Also reset create-time contact-OTP verification — an OTP verified against a // previous mobile number no longer applies once the value is edited. Belt-and-braces // alongside ContactOtpBlockComponent's own destination-change reset: this fires even @@ -1282,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => { this.alternateMobileVerified = false; + this.alternateDipRejected = false; + this.alternateDipStatus = null; + this.alternateDipLookupLogId = null; }); // Reset create-time contact-OTP verification when email address changes — see the @@ -1322,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges }); dialogRef.afterClosed().subscribe((result: any) => { if (!result) return; - if (result.verified) { - this.mobileVerified = true; - } + // A flagged number the officer chose to proceed with counts as verified — the decision and + // its reason are already stored against the lookup, which is what the save gate checks. + this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED'; + this.dipRejected = result.decision === 'REJECTED'; + this.dipStatus = result.status || null; this.dipLookupLogId = result.lookupLogId || null; this.dipTimestamp = result.timestamp || null; }); @@ -1342,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges }); dialogRef.afterClosed().subscribe((result: any) => { if (!result) return; - if (result.verified) { - this.alternateMobileVerified = true; - } + this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED'; + this.alternateDipRejected = result.decision === 'REJECTED'; + this.alternateDipStatus = result.status || null; this.alternateDipLookupLogId = result.lookupLogId || null; this.alternateDipTimestamp = result.timestamp || null; }); @@ -1387,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges */ verifySanctions() { const name = this.currentNameSnapshot(); + const dob = this.createClientForm.get('dateOfBirth')?.value; const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, { - width: '550px', - data: { name } + width: '620px', + data: { + name, + // The dialog compares these against the listed particulars — a name-only hit cannot be + // judged without the applicant's own identifiers next to it. + dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '', + nationality: this.nationalityLabel() + } }); dialogRef.afterClosed().subscribe((result: any) => { if (!result) return; @@ -1399,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // CLEAR — no match found this.sanctionsVerified = true; this.sanctionsBlocked = false; + this.sanctionsDisposition = null; + this.sanctionsNeedsRescreen = false; this.verifiedName = name; } else if (result.verified === false) { - // FLAGGED — match found, block account opening + // FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal + // Officer, not a clearance; only their approval lets this name through. this.sanctionsVerified = false; this.sanctionsBlocked = true; + this.sanctionsDisposition = result.disposition || null; + this.sanctionsNeedsRescreen = false; this.verifiedName = ''; } }); } + /** Human-readable nationality for the sanctions comparison panel. */ + private nationalityLabel(): string { + return this.getSelectedNationality()?.label || ''; + } + /** Concatenated current name from the form (handles individual + entity) */ private currentNameSnapshot(): string { const firstname = this.createClientForm.get('firstname')?.value || ''; @@ -1448,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // Only invalidate if a verification was previously recorded if (!this.sanctionsVerified && !this.sanctionsBlocked) return; if (this.currentNameSnapshot() !== this.verifiedName) { + // Editing the name forces a fresh screening, but must NOT silently clear an existing + // block: otherwise a flagged applicant is waved through by nudging one character. The + // block is only lifted by re-running the check and getting a CLEAR back from the API. this.sanctionsVerified = false; - this.sanctionsBlocked = false; this.screeningLogId = null; + if (this.sanctionsBlocked) { + this.sanctionsNeedsRescreen = true; + } } }); } diff --git a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts index d7a857761..9ac055f0c 100644 --- a/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts +++ b/src/app/clients/client-stepper/client-general-step/dip-verify-dialog.component.ts @@ -1,8 +1,17 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatButtonModule } from '@angular/material/button'; +import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ClientsService } from 'app/clients/clients.service'; @@ -31,13 +40,18 @@ import { ClientsService } from 'app/clients/clients.service'; This may take a few seconds. Do not close or refresh this window.
} + @if (!verifying && serviceUnavailable) { -
- -

DIP & MNRL verification completed successfully.

-
- - Mobile number is CLEAN — not found in the revocation list. +
+ +

Could not verify — DIP/MNRL service unavailable

+
+ This number has not been checked against the DoT Digital Intelligence Platform. Retry once + the service is reachable, or proceed with a recorded reason.
} @@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';

{{ error }}

} + + + @if (!verifying && priorRejection) { +
+ +
+ This number was rejected before. +
+ Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} — + status was {{ priorRejection.lookupStatus }}. +
+
+
+ } + + @if (!verifying && needsDecision && showReasonForm) { +
+ + +
+ Minimum 10 characters — this reason is kept on the lookup record. +
+ @if (decisionError) { +
{{ decisionError }}
+ } +
+ } - @if (!verifying) { + @if (!verifying && !needsDecision) { } + + @if (!verifying && needsDecision && !showReasonForm) { + + + } + @if (!verifying && needsDecision && showReasonForm) { + + + } `, styles: [ @@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service'; color: #d32f2f; } + .unavailable-icon { + color: #b45309; + } + + .unavailable-msg { + color: #b45309; + } + + .unavailable-detail { + font-size: 13px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + line-height: 1.6; + margin-top: 8px; + } + + .prior-rejection { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 13px; + color: #7f1d1d; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 6px; + padding: 10px 12px; + margin-top: 12px; + line-height: 1.5; + } + + .prior-icon { + color: #dc2626; + font-size: 16px; + margin-top: 2px; + } + + .prior-detail { + font-size: 12px; + color: #991b1b; + margin-top: 2px; + } + + .reason-form { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 14px; + } + + .reason-label { + font-size: 12px; + font-weight: 600; + color: #374151; + } + + .reason-input { + width: 100%; + box-sizing: border-box; + font-family: inherit; + font-size: 13px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + resize: vertical; + } + + .reason-input:focus { + outline: none; + border-color: #1074b9; + } + + .reason-hint { + font-size: 11px; + color: #9ca3af; + } + + .reason-error { + color: #dc2626; + font-weight: 600; + } + mat-dialog-actions { padding: 8px 24px 16px; + gap: 8px; } ` ], imports: [ CommonModule, + FormsModule, MatDialogModule, MatProgressBarModule, MatButtonModule, @@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy { suspectedStatus = ''; suspectedReason = ''; suspectedReportedBy = ''; + + /** Set when this number was rejected on an earlier client — history the officer must see. */ + priorRejection: any = null; + + /** Officer decision capture for a flagged or unverifiable number. */ + showReasonForm = false; + decisionReason = ''; + reasonTouched = false; + saving = false; + decisionError = ''; + + /** A hit or an unverified number cannot simply be dismissed with OK. */ + get needsDecision(): boolean { + return this.suspected || this.serviceUnavailable; + } + + get reasonValid(): boolean { + return this.decisionReason.trim().length >= 10; + } private subscription: any; private intervalId: any; private apiResponse: any = null; @@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy { } const res = this.apiResponse; + this.priorRejection = res?.priorRejection || null; if (res?.data?.status === 'SERVICE_UNAVAILABLE') { this.serviceUnavailable = true; this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.'; @@ -329,11 +502,68 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy { } } + startProceed() { + this.showReasonForm = true; + this.reasonTouched = false; + this.decisionError = ''; + } + + cancelProceed() { + this.showReasonForm = false; + this.decisionReason = ''; + this.decisionError = ''; + } + + submitProceed() { + this.reasonTouched = true; + if (!this.reasonValid) { + return; + } + this.recordDecision('PROCEEDED', this.decisionReason.trim(), true); + } + + reject() { + this.recordDecision('REJECTED', 'Number rejected at onboarding following DIP/MNRL result.', false); + } + + /** + * Persists the decision before closing. The number is only usable downstream once the backend + * has stored PROCEEDED — the client-create gate re-checks this, so closing without saving + * would simply fail at save time with no explanation. + */ + private recordDecision(decision: string, reason: string, verified: boolean) { + const logId = this.apiResponse?.lookupLogId; + if (!logId) { + this.decisionError = 'Lookup reference missing — re-run the check before recording a decision.'; + return; + } + this.saving = true; + this.decisionError = ''; + this.clientsService.recordDipDecision(logId, decision, reason).subscribe({ + next: () => { + this.saving = false; + this.dialogRef.close({ + verified, + decision, + lookupLogId: logId, + status: this.apiResponse?.data?.status || null, + timestamp: this.apiResponse?.timestamp || null + }); + }, + error: (err: any) => { + this.saving = false; + this.decisionError = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.'; + } + }); + } + close() { - const verified = !this.suspected && !this.error; + const verified = !this.suspected && !this.error && !this.serviceUnavailable; this.dialogRef.close({ verified, lookupLogId: this.apiResponse?.lookupLogId || null, + status: this.apiResponse?.data?.status || null, timestamp: this.apiResponse?.timestamp || null }); } diff --git a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts index 540dc0c20..752a3654e 100644 --- a/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts +++ b/src/app/clients/client-stepper/client-general-step/sanctions-verify-dialog.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatButtonModule } from '@angular/material/button'; +import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ClientsService } from 'app/clients/clients.service'; @@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
} + +
+ + Compare the applicant against the listed particulars +
+
+
Applicant
+
+ {{ clientName }} + DOB: {{ applicantDob || '—' }} + Nationality: {{ applicantNationality || '—' }} +
+
+
+ @for (m of matches; track m.id) { +
+
{{ m.fullName }}
+
+ {{ m.entryType }} + @if (m.unListType) { + {{ m.unListType }} + } + @if (m.referenceNumber) { + {{ m.referenceNumber }} + } +
+
+ DOB: {{ m.dateOfBirth || '—' }} · Nationality: {{ m.nationality || '—' }} · Place of + birth: {{ m.placeOfBirth || '—' }} +
+ @if (m.aliases?.length) { +
Aliases: {{ m.aliases.join('; ') }}
+ } + @if (m.listedOn) { +
Listed on: {{ m.listedOn }}
+ } +
+ } +
Escalate to Principal Officer immediately. Account opening blocked.
+ @if (showReasonForm) { +
+ + +
+ Minimum 10 characters — this text is the compliance record. +
+ @if (dispositionError) { +
{{ dispositionError }}
+ } +
+ }

{{ regulatoryBasis }}

} @@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service'; } @if (!verifying && matchFound) { - + @if (!showReasonForm) { + + + } + @if (showReasonForm) { + + + } } @if (!verifying && errorOccurred) { @@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service'; mat-dialog-content { padding: 16px 24px; - max-height: unset; - overflow: visible; + /* + The match card lists every hit with its aliases, so this content is now tall enough to + push the action row off-screen if the dialog is allowed to grow unbounded — which left + "Not the Same Person" unreachable. Scroll the body instead and keep the buttons pinned. + */ + max-height: 62vh; + overflow-y: auto; } .progress-section { @@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service'; .match-list { width: 100%; - max-height: unset; - overflow: visible; margin: 8px 0; } @@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service'; margin-top: 4px; } + .compare-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #6b7280; + margin-top: 4px; + } + + .applicant-card { + width: 100%; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-left: 4px solid #1074b9; + border-radius: 4px; + padding: 8px 12px; + } + + .applicant-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #1074b9; + } + + .applicant-values { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: #374151; + margin-top: 2px; + } + + .reason-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + } + + .reason-label { + font-size: 12px; + font-weight: 600; + color: #374151; + } + + .reason-input { + width: 100%; + box-sizing: border-box; + font-family: inherit; + font-size: 13px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + resize: vertical; + } + + .reason-input:focus { + outline: none; + border-color: #1074b9; + } + + .reason-hint { + font-size: 11px; + color: #9ca3af; + } + + .reason-error { + color: #dc2626; + font-weight: 600; + } + .block-msg { font-size: 14px; font-weight: 600; @@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service'; mat-dialog-actions { padding: 8px 24px 16px; + /* Sits below a scrolling body, so mark it off clearly as the fixed action row. */ + border-top: 1px solid #e5e7eb; + gap: 8px; } ` ], imports: [ CommonModule, + FormsModule, MatDialogModule, MatProgressBarModule, MatButtonModule, @@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service'; }) export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy { private dialogRef = inject(MatDialogRef); - private data: { name: string } = inject(MAT_DIALOG_DATA); + private data: { name: string; dateOfBirth?: string; nationality?: string } = inject(MAT_DIALOG_DATA); private clientsService = inject(ClientsService); clientName = ''; + /** Applicant's own identifiers, shown beside the listed particulars for comparison. */ + applicantDob = ''; + applicantNationality = ''; verifying = true; matchFound = false; matchCount = 0; @@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy { screeningLogId: number | null = null; listDataAsOf: string | null = null; + /** Disposition capture — a hit must be closed out, not merely acknowledged. */ + showReasonForm = false; + pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null; + dispositionReason = ''; + reasonTouched = false; + savingDisposition = false; + dispositionError = ''; + + /** Mirrors the backend's minimum: an unexplained assessment is not a review. */ + get reasonValid(): boolean { + return this.dispositionReason.trim().length >= 10; + } + /** * Cites the direction actually in force. Chapter IX ("Requirements/obligations under International * Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded @@ -599,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy { ngOnInit() { this.clientName = this.data?.name || ''; + this.applicantDob = this.data?.dateOfBirth || ''; + this.applicantNationality = this.data?.nationality || ''; this.dialogRef.disableClose = true; // Start API call immediately @@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy { clearInterval(this.progressTimer); } + startDisposition(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') { + this.pendingDisposition = disposition; + this.showReasonForm = true; + this.reasonTouched = false; + this.dispositionError = ''; + } + + cancelDisposition() { + this.showReasonForm = false; + this.pendingDisposition = null; + this.dispositionReason = ''; + this.dispositionError = ''; + } + + submitDisposition() { + this.reasonTouched = true; + if (!this.reasonValid || !this.pendingDisposition) { + return; + } + // Without a log id there is nothing to attach the assessment to — close as a plain block + // rather than silently discarding the officer's reasoning. + if (!this.screeningLogId) { + this.dispositionError = 'Screening reference missing — re-run the check before recording an outcome.'; + return; + } + + this.savingDisposition = true; + this.clientsService + .recordSanctionsDisposition(this.screeningLogId, this.pendingDisposition, this.dispositionReason.trim()) + .subscribe({ + next: () => { + this.savingDisposition = false; + // Both outcomes keep the client blocked. A false positive only takes effect once the + // Principal Officer approves it from the Sanctions Logs screen. + this.dialogRef.close({ + verified: false, + screeningLogId: this.screeningLogId, + disposition: this.pendingDisposition + }); + }, + error: (err: any) => { + this.savingDisposition = false; + this.dispositionError = + err?.error?.defaultUserMessage || + err?.error?.message || + err?.message || + 'Could not save the review outcome.'; + } + }); + } + close(verified: boolean) { this.dialogRef.close({ verified, screeningLogId: this.screeningLogId }); } diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index b84bf8709..2145f060a 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -855,6 +855,30 @@ export class ClientsService { return this.http.post('/sanctions/screen', { name }); } + /** + * Records the reviewing officer's assessment of a flagged screening. + * + * A FALSE_POSITIVE does not unblock onboarding on its own — it only becomes effective once + * the Principal Officer approves it from the Sanctions Logs screen. + * + * @param logId screening log id returned by screenSanctions() + * @param disposition 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' + * @param reason which identifiers were compared and why they do/don't match + */ + recordSanctionsDisposition(logId: number, disposition: string, reason: string): Observable { + return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason }); + } + + /** + * Records the officer's decision on a DIP/MNRL lookup that came back flagged or unverifiable. + * Until PROCEEDED is stored, the client-create gate refuses to save the number. + * + * @param decision 'PROCEEDED' | 'REJECTED' + */ + recordDipDecision(logId: number, decision: string, reason: string): Observable { + return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason }); + } + /** * Updates existing KYC details for a client. * @param clientId Client ID diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 8fcde0678..6218ea5ac 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -214,6 +214,8 @@ export class CreateClientComponent { if (idx === 0) { if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; + // A number the officer rejected on the DIP result must be replaced, not carried forward. + if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false; if (!this.clientGeneralForm.valid) return false; // Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new // client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent. @@ -273,7 +275,17 @@ export class CreateClientComponent { if (this.currentStepValid) return ''; const idx = this.selectedTabIndex; if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) { - return 'Client failed sanctions screening — cannot proceed.'; + const disposition = this.clientGeneralStep.sanctionsDisposition; + if (disposition === 'FALSE_POSITIVE') { + return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.'; + } + if (disposition === 'CONFIRMED_MATCH') { + return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.'; + } + return 'Sanctions match found — review the match and record an outcome before proceeding.'; + } + if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) { + return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.'; } if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) { return 'Verify the OTP sent to both the mobile number and email address before proceeding.'; diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 3cc507649..c55beef28 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -381,7 +381,14 @@ export class EditClientComponent { if (this.savingClient) return 'Saving — please wait…'; if (this.currentStepValid) return ''; if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) { - return 'Client failed sanctions screening — cannot proceed.'; + const disposition = this.clientGeneralStep.sanctionsDisposition; + if (disposition === 'FALSE_POSITIVE') { + return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.'; + } + if (disposition === 'CONFIRMED_MATCH') { + return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.'; + } + return 'Sanctions match found — review the match and record an outcome before proceeding.'; } if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) { return 'Verify the OTP sent to both the old and new mobile number/email before saving.'; diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index d52c27b31..e60dd5be3 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -607,6 +607,16 @@ + + + diff --git a/src/app/reports/dip-lookups/dip-decision-dialog.component.ts b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts new file mode 100644 index 000000000..408a421d2 --- /dev/null +++ b/src/app/reports/dip-lookups/dip-decision-dialog.component.ts @@ -0,0 +1,349 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DipLookupsService } from './dip-lookups.service'; + +/** + * Records — or displays — the officer's decision on a flagged DIP/MNRL lookup. + * + * The same decision can be taken inline during onboarding; this is the back-office path for + * numbers that were left undecided, and the read-only view once a decision exists. + */ +@Component({ + selector: 'mifosx-dip-decision-dialog', + template: ` +

+ + Mobile Lookup #{{ row.id }} +

+ +
+
+ Mobile Number + {{ row.mobileNumber }} +
+
+ Status + {{ row.lookupStatus }} +
+
+ Result + {{ row.responseMessage || '—' }} +
+
+ Source + {{ row.lookupSource || '—' }} +
+
+ Client + {{ row.clientId || 'Not onboarded' }} +
+
+ Looked Up + {{ row.lookedUpOn | date: 'medium' }} +
+
+ + @if (row.overrideDecision) { +
Recorded Decision
+
+
+ {{ row.overrideDecision === 'PROCEEDED' ? 'PROCEEDED' : 'REJECTED' }} +
+

{{ row.overrideReason || 'No reason recorded.' }}

+
+ Decided by user {{ row.decidedBy || '—' }} on {{ row.decidedOn | date: 'medium' }} +
+
+ } @else { +
Record a Decision
+
+ + This number is flagged and has not been decided. It cannot be saved on a client until an officer proceeds with + reasons. +
+ @if (showReasonForm) { +
+ + +
+ Minimum 10 characters — this reason is kept on the lookup record. +
+
+ } + } + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + @if (!row.overrideDecision && !showReasonForm) { + + + } + @if (!row.overrideDecision && showReasonForm) { + + } + + `, + styles: [ + ` + :host { + display: block; + min-width: 500px; + } + + h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 16px 24px; + font-size: 18px; + font-weight: 600; + color: #1074b9; + } + + mat-dialog-content { + padding: 16px 24px; + } + + .detail-grid { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px 14px; + } + + .detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 6px 0; + border-bottom: 1px solid #f3f4f6; + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + font-size: 12px; + color: #9ca3af; + font-weight: 500; + white-space: nowrap; + } + + .detail-value { + font-size: 12px; + color: #374151; + font-weight: 600; + text-align: right; + } + + .detail-value.strong { + color: #111827; + font-size: 13px; + } + + .detail-value.danger { + color: #dc2626; + } + + .section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #6b7280; + margin: 16px 0 6px; + } + + .pending-note { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + line-height: 1.5; + } + + .decision-card { + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-left: 4px solid #059669; + border-radius: 6px; + padding: 10px 14px; + } + + .decision-card.decision-rejected { + background: #fef2f2; + border-color: #fecaca; + border-left-color: #dc2626; + } + + .decision-badge { + font-size: 11px; + font-weight: 800; + letter-spacing: 1px; + color: #059669; + } + + .decision-rejected .decision-badge { + color: #dc2626; + } + + .decision-reason { + font-size: 13px; + color: #374151; + line-height: 1.6; + margin: 6px 0; + white-space: pre-wrap; + } + + .decision-meta { + font-size: 11px; + color: #9ca3af; + } + + .reason-form { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; + } + + .field-label { + font-size: 12px; + font-weight: 600; + color: #374151; + } + + .field-input { + width: 100%; + box-sizing: border-box; + font-family: inherit; + font-size: 13px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + resize: vertical; + } + + .field-input:focus { + outline: none; + border-color: #1074b9; + } + + .field-hint { + font-size: 11px; + color: #9ca3af; + } + + .field-error { + color: #dc2626; + font-weight: 600; + } + + .error-note { + font-size: 12px; + font-weight: 600; + color: #dc2626; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 8px 12px; + margin-top: 12px; + } + + mat-dialog-actions { + padding: 8px 24px 16px; + gap: 8px; + } + ` + ], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + FaIconComponent + ] +}) +export class DipDecisionDialogComponent { + private dialogRef = inject(MatDialogRef); + private service = inject(DipLookupsService); + + row: any = inject(MAT_DIALOG_DATA); + + showReasonForm = false; + reason = ''; + touched = false; + saving = false; + errorMessage = ''; + private changed = false; + + get reasonValid(): boolean { + return this.reason.trim().length >= 10; + } + + proceed() { + this.touched = true; + if (!this.reasonValid) { + return; + } + this.save('PROCEEDED', this.reason.trim()); + } + + reject() { + this.save('REJECTED', 'Number rejected on review of the DIP/MNRL result.'); + } + + private save(decision: string, reason: string) { + this.saving = true; + this.errorMessage = ''; + this.service.recordDecision(this.row.id, decision, reason).subscribe({ + next: (updated: any) => { + this.saving = false; + this.changed = true; + this.row = updated || this.row; + this.showReasonForm = false; + }, + error: (err: any) => { + this.saving = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.'; + } + }); + } + + close() { + this.dialogRef.close(this.changed); + } +} diff --git a/src/app/reports/dip-lookups/dip-lookups.component.html b/src/app/reports/dip-lookups/dip-lookups.component.html new file mode 100644 index 000000000..09ef230bc --- /dev/null +++ b/src/app/reports/dip-lookups/dip-lookups.component.html @@ -0,0 +1,131 @@ + + +
+ + +
+ + Search by Mobile Number + + + + +
+ + + + + + + @if (searchNumber || activeStatus || pendingOnly) { + + } +
+
+ + @if (loading) { + + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mobile Number{{ row.mobileNumber }}Status + {{ row.lookupStatus }} + Decision + {{ decisionLabel(row) }} + Source{{ row.lookupSource || '—' }}Client{{ row.clientId || 'Not onboarded' }}Looked Up{{ row.lookedUpOn | date: 'medium' }}Review + @if (needsDecision(row) || row.overrideDecision) { + + } +
+ + @if (!loading && dataSource.data.length === 0) { +
+ +

No mobile lookups found.

+
+ } + + +
+
diff --git a/src/app/reports/dip-lookups/dip-lookups.component.scss b/src/app/reports/dip-lookups/dip-lookups.component.scss new file mode 100644 index 000000000..1a8c37aba --- /dev/null +++ b/src/app/reports/dip-lookups/dip-lookups.component.scss @@ -0,0 +1,152 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.page-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + + .header-icon { + font-size: 24px; + color: #1074b9; + } + + h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #333; + } +} + +.filters-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; + + .search-field { + flex: 1; + min-width: 250px; + max-width: 400px; + } + + .search-icon { + color: #999; + margin-right: 4px; + } +} + +.filter-chips { + display: flex; + align-items: center; + gap: 8px; + + button { + font-size: 13px; + border-radius: 20px; + } + + .chip-icon { + margin-right: 4px; + font-size: 12px; + } + + .active-filter { + background-color: #1074b9; + color: #fff; + border-color: #1074b9; + } + + .chip-clean.active-filter { + background-color: #388e3c; + border-color: #388e3c; + } + + .chip-flagged.active-filter { + background-color: #d32f2f; + border-color: #d32f2f; + } + + .chip-pending.active-filter { + background-color: #b45309; + border-color: #b45309; + } +} + +.result-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.badge-clean { + background: #e8f5e9; + color: #2e7d32; +} + +.badge-flagged { + background: #ffebee; + color: #c62828; +} + +/* Reviewed but not yet signed off by the Principal Officer — still blocking. */ +.badge-pending { + background: #fffbeb; + color: #b45309; +} + +.muted { + color: #9ca3af; +} + +.review-btn { + font-size: 12px; + border-radius: 16px; + + fa-icon { + margin-right: 4px; + } +} + +.select-row { + cursor: pointer; + + &:hover { + background: #f5f5f5; + } +} + +.no-data { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 0; + color: #999; + + .no-data-icon { + font-size: 40px; + margin-bottom: 8px; + } + + p { + font-size: 14px; + margin: 0; + } +} + +mat-progress-bar { + margin-bottom: 8px; +} diff --git a/src/app/reports/dip-lookups/dip-lookups.component.ts b/src/app/reports/dip-lookups/dip-lookups.component.ts new file mode 100644 index 000000000..d8fad5230 --- /dev/null +++ b/src/app/reports/dip-lookups/dip-lookups.component.ts @@ -0,0 +1,178 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatDialog } from '@angular/material/dialog'; +import { NgClass, DatePipe } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DipLookupsService } from './dip-lookups.service'; +import { DipDecisionDialogComponent } from './dip-decision-dialog.component'; + +/** + * DIP / MNRL lookup activity and the decision queue. + * + * Every mobile number screened against the DoT Digital Intelligence Platform is logged, but the + * data had no screen at all — a flagged number could sit undecided with nobody able to see it. + */ +@Component({ + selector: 'mifosx-dip-lookups', + templateUrl: './dip-lookups.component.html', + styleUrls: ['./dip-lookups.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + MatProgressBarModule, + MatTooltip, + NgClass, + DatePipe, + FaIconComponent + ] +}) +export class DipLookupsComponent implements OnInit { + private service = inject(DipLookupsService); + private dialog = inject(MatDialog); + + displayedColumns: string[] = [ + 'mobileNumber', + 'lookupStatus', + 'decision', + 'lookupSource', + 'clientId', + 'lookedUpOn', + 'actions' + ]; + + dataSource = new MatTableDataSource(); + searchNumber = ''; + activeStatus: string | null = null; + /** True while the table is showing the pending-decision queue rather than the full log. */ + pendingOnly = false; + loading = false; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort, { static: true }) sort: MatSort; + + ngOnInit() { + this.load(); + } + + load() { + this.loading = true; + const request$ = this.pendingOnly + ? this.service.getPending() + : this.service.getLogs({ + mobileNumber: this.searchNumber?.trim() || undefined, + status: this.activeStatus || undefined + }); + + request$.subscribe({ + next: (rows: any) => this.applyRows(rows || []), + error: () => this.applyRows([]) + }); + } + + private applyRows(rows: any[]) { + this.dataSource = new MatTableDataSource(rows); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.loading = false; + } + + applySearch(value: string) { + this.searchNumber = value; + this.pendingOnly = false; + this.load(); + } + + filterByStatus(status: string | null) { + this.activeStatus = this.activeStatus === status ? null : status; + this.pendingOnly = false; + this.load(); + } + + showPending() { + this.pendingOnly = !this.pendingOnly; + this.activeStatus = null; + this.load(); + } + + clearFilters() { + this.searchNumber = ''; + this.activeStatus = null; + this.pendingOnly = false; + this.load(); + } + + statusClass(row: any): string { + if (row?.lookupStatus === 'CLEAN') return 'badge-clean'; + if (row?.lookupStatus === 'SERVICE_UNAVAILABLE') return 'badge-pending'; + return 'badge-flagged'; + } + + decisionLabel(row: any): string { + if (row?.overrideDecision === 'PROCEEDED') return 'Proceeded'; + if (row?.overrideDecision === 'REJECTED') return 'Rejected'; + // Only a hit or an unverifiable lookup is actually awaiting anything; a clean number needs + // no decision at all, so it should not read as if something is outstanding. + return row?.suspected || row?.disconnected || row?.lookupStatus === 'SERVICE_UNAVAILABLE' + ? 'Awaiting Decision' + : '—'; + } + + decisionClass(row: any): string { + if (row?.overrideDecision === 'PROCEEDED') return 'badge-clean'; + if (row?.overrideDecision === 'REJECTED') return 'badge-flagged'; + return this.needsDecision(row) ? 'badge-pending' : ''; + } + + needsDecision(row: any): boolean { + const flagged = row?.suspected || row?.disconnected || row?.lookupStatus === 'SERVICE_UNAVAILABLE'; + return flagged && !row?.overrideDecision; + } + + review(row: any) { + this.dialog + .open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false }) + .afterClosed() + .subscribe((changed: boolean) => { + if (changed) { + this.load(); + } + }); + } +} diff --git a/src/app/reports/dip-lookups/dip-lookups.service.ts b/src/app/reports/dip-lookups/dip-lookups.service.ts new file mode 100644 index 000000000..bf4c24668 --- /dev/null +++ b/src/app/reports/dip-lookups/dip-lookups.service.ts @@ -0,0 +1,38 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** DIP / MNRL mobile number lookup activity and officer decisions. */ +@Injectable({ providedIn: 'root' }) +export class DipLookupsService { + private http = inject(HttpClient); + + getLogs(filters?: { mobileNumber?: string; status?: string }): Observable { + let params = new HttpParams(); + if (filters?.mobileNumber) { + params = params.set('mobileNumber', filters.mobileNumber); + } + if (filters?.status) { + params = params.set('status', filters.status); + } + return this.http.get('/lookup/logs', { params }); + } + + /** Flagged or unverifiable numbers on which no decision has been recorded yet. */ + getPending(): Observable { + return this.http.get('/lookup/pending'); + } + + /** @param decision 'PROCEEDED' | 'REJECTED' */ + recordDecision(logId: number, decision: string, reason: string): Observable { + return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason }); + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 7565d21ec..c19074d2a 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -43,6 +43,11 @@ const routes: Routes = [ data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' }, component: SanctionsLogsComponent }, + { + path: 'dip-lookups', + data: { title: 'DIP / MNRL Lookups', breadcrumb: 'DIP / MNRL Lookups' }, + loadComponent: () => import('./dip-lookups/dip-lookups.component').then((m) => m.DipLookupsComponent) + }, // Must sit above ':filter', which would otherwise match this path and // render an empty report category instead. { diff --git a/src/app/reports/reports.component.html b/src/app/reports/reports.component.html index 5025998dc..80216b9ae 100644 --- a/src/app/reports/reports.component.html +++ b/src/app/reports/reports.component.html @@ -14,6 +14,15 @@

{{ 'labels.menus.Reports' | translate }}

{{ reportsData?.length || 0 }} reports + + + + Sanctions Screening & Alerts + diff --git a/src/app/reports/reports.component.scss b/src/app/reports/reports.component.scss index 686fd2d24..ff2be74cc 100644 --- a/src/app/reports/reports.component.scss +++ b/src/app/reports/reports.component.scss @@ -38,6 +38,29 @@ $mono: 'Roboto Mono', 'DM Mono', monospace; margin: 0; } + &__action { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 20px; + padding: 7px 16px; + transition: + background 0.2s ease, + border-color 0.2s ease; + + &:hover { + background: #fee2e2; + border-color: #fca5a5; + } + } + &__badge { font-size: 11px; font-family: $mono; diff --git a/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts new file mode 100644 index 000000000..3570653dd --- /dev/null +++ b/src/app/reports/sanctions-logs/sanctions-alert-review-dialog.component.ts @@ -0,0 +1,430 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { SanctionsLogsService } from './sanctions-logs.service'; + +/** + * Principal Officer review of a UNSC sanctions alert. + * + * This is the checker half of the flow: the branch officer records whether the hit is the same + * person, and nothing takes effect until it is approved here. Approving a false positive clears + * the name for onboarding; approving a confirmed match freezes the client's accounts and opens + * the FIU-IND / nodal officer reporting fields for the 24-hour obligation. + */ +@Component({ + selector: 'mifosx-sanctions-alert-review-dialog', + template: ` +

+ + Sanctions Alert #{{ alert.id }} +

+ +
+
+ Screened Name + {{ alert.screenedName }} +
+
+ Matched On List + {{ alert.matchedNames || '—' }} +
+
+ Matches + {{ alert.matchCount || 0 }} +
+
+ Source + {{ alert.screeningSource || '—' }} +
+
+ Client + {{ alert.clientId || 'Not yet onboarded' }} +
+
+ Screened On + {{ alert.screenedOn | date: 'medium' }} +
+
+ +
Reviewing Officer's Assessment
+ @if (alert.disposition === 'PENDING_REVIEW') { +
+ + Not yet reviewed. The branch officer must record an outcome before this can be approved. +
+ } @else { +
+
{{ dispositionLabel }}
+

{{ alert.dispositionReason || 'No reason recorded.' }}

+
+ Reviewed by user {{ alert.reviewedBy || '—' }} on {{ alert.reviewedOn | date: 'medium' }} +
+
+ } + + @if (alert.approvedBy) { +
+ + Approved by user {{ alert.approvedBy }} on {{ alert.approvedOn | date: 'medium' }} + @if (alert.frozenAccountIds) { + · Debits frozen on account(s) {{ alert.frozenAccountIds }} + } +
+ } + + @if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) { +
FIU-IND / Nodal Officer Reporting
+
+ + + + +
+ Section 51-A UAPA requires FIU-IND and the nodal officer to be informed within 24 hours of confirming the + match. +
+ + @if (savedMessage) { +
+ + {{ savedMessage }} +
+ } +
+ } + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+ + + @if (canApprove) { + + } + + `, + styles: [ + ` + :host { + display: block; + min-width: 520px; + } + + h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 16px 24px; + font-size: 18px; + font-weight: 600; + color: #1074b9; + } + + mat-dialog-content { + padding: 16px 24px; + } + + .detail-grid { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px 14px; + } + + .detail-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 6px 0; + border-bottom: 1px solid #f3f4f6; + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + font-size: 12px; + color: #9ca3af; + font-weight: 500; + white-space: nowrap; + } + + .detail-value { + font-size: 12px; + color: #374151; + font-weight: 600; + text-align: right; + } + + .detail-value.strong { + color: #111827; + font-size: 13px; + } + + .section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: #6b7280; + margin: 16px 0 6px; + } + + .pending-note { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + } + + .assessment-card { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-left: 4px solid #1074b9; + border-radius: 6px; + padding: 10px 14px; + } + + .assessment-card.assessment-confirmed { + background: #fef2f2; + border-color: #fecaca; + border-left-color: #dc2626; + } + + .assessment-badge { + font-size: 11px; + font-weight: 800; + letter-spacing: 1px; + color: #1074b9; + } + + .assessment-confirmed .assessment-badge { + color: #dc2626; + } + + .assessment-reason { + font-size: 13px; + color: #374151; + line-height: 1.6; + margin: 6px 0; + white-space: pre-wrap; + } + + .assessment-meta { + font-size: 11px; + color: #9ca3af; + } + + .approved-note { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: #059669; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 6px; + padding: 8px 12px; + margin-top: 10px; + } + + .report-form { + display: flex; + flex-direction: column; + gap: 4px; + } + + .field-label { + font-size: 12px; + font-weight: 600; + color: #374151; + margin-top: 6px; + } + + .field-input { + font-family: inherit; + font-size: 13px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 4px; + } + + .field-input:focus { + outline: none; + border-color: #1074b9; + } + + .field-hint { + font-size: 11px; + color: #9ca3af; + margin-top: 4px; + } + + .report-btn { + align-self: flex-start; + margin-top: 8px; + } + + .saved-note { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: #059669; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 6px; + padding: 8px 12px; + margin-top: 8px; + } + + .error-note { + font-size: 12px; + font-weight: 600; + color: #dc2626; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 8px 12px; + margin-top: 12px; + } + + mat-dialog-actions { + padding: 8px 24px 16px; + } + ` + ], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + FaIconComponent + ] +}) +export class SanctionsAlertReviewDialogComponent { + private dialogRef = inject(MatDialogRef); + private service = inject(SanctionsLogsService); + + alert: any = inject(MAT_DIALOG_DATA); + + fiuStrReference = ''; + nodalOfficerInformedOn = ''; + saving = false; + errorMessage = ''; + savedMessage = ''; + /** Set when anything was persisted, so the caller knows to refresh the table. */ + private changed = false; + + constructor() { + this.fiuStrReference = this.alert?.fiuStrReference || ''; + this.nodalOfficerInformedOn = this.alert?.nodalOfficerInformedOn + ? String(this.alert.nodalOfficerInformedOn).substring(0, 10) + : ''; + } + + get dispositionLabel(): string { + return this.alert?.disposition === 'FALSE_POSITIVE' ? 'NOT THE SAME PERSON' : 'CONFIRMED MATCH'; + } + + /** Only a reviewed, not-yet-approved alert is actionable here. */ + get canApprove(): boolean { + return this.alert?.disposition && this.alert.disposition !== 'PENDING_REVIEW' && !this.alert.approvedBy; + } + + get approveLabel(): string { + return this.alert?.disposition === 'FALSE_POSITIVE' + ? 'Approve — Clear for Onboarding' + : 'Approve — Confirm & Freeze'; + } + + approve() { + this.saving = true; + this.errorMessage = ''; + this.savedMessage = ''; + this.service.approveAlert(this.alert.id).subscribe({ + next: (updated: any) => { + this.saving = false; + this.changed = true; + this.alert = updated || this.alert; + }, + error: (err: any) => { + this.saving = false; + this.errorMessage = this.extractMessage(err, 'Could not approve this alert.'); + } + }); + } + + saveReporting() { + this.errorMessage = ''; + this.savedMessage = ''; + + const strReference = this.fiuStrReference.trim(); + if (strReference.length < 4) { + this.errorMessage = 'Enter the acknowledgement reference FIU-IND returned for the STR.'; + return; + } + if (!this.nodalOfficerInformedOn) { + this.errorMessage = 'Enter the date the nodal officer was informed.'; + return; + } + // The intimation cannot predate the screening that triggered it — a backdated entry here is + // exactly the kind of record a regulator would treat as fabricated. + const screenedOn = this.alert?.screenedOn ? new Date(this.alert.screenedOn) : null; + if (screenedOn && !isNaN(screenedOn.getTime())) { + const screenedDay = new Date(screenedOn.getFullYear(), screenedOn.getMonth(), screenedOn.getDate()); + if (new Date(this.nodalOfficerInformedOn) < screenedDay) { + this.errorMessage = 'The nodal officer cannot have been informed before the match was screened.'; + return; + } + } + + this.saving = true; + this.service.recordReporting(this.alert.id, strReference, this.nodalOfficerInformedOn).subscribe({ + next: (updated: any) => { + this.saving = false; + this.changed = true; + this.alert = updated || this.alert; + this.savedMessage = 'Reporting details saved against this alert.'; + }, + error: (err: any) => { + this.saving = false; + this.errorMessage = this.extractMessage(err, 'Could not save the reporting details.'); + } + }); + } + + close() { + this.dialogRef.close(this.changed); + } + + private extractMessage(err: any, fallback: string): string { + return err?.error?.defaultUserMessage || err?.error?.message || err?.message || fallback; + } +} diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.html b/src/app/reports/sanctions-logs/sanctions-logs.component.html index 6d90a0da8..976cf44a1 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.component.html +++ b/src/app/reports/sanctions-logs/sanctions-logs.component.html @@ -39,12 +39,44 @@ > Flagged - @if (searchName || activeFilter) { + + + + @if (searchName || activeFilter || alertFilter) { } + + @if (loading) { @@ -72,6 +104,17 @@ + + Disposition + + @if (isAlert(log)) { + {{ dispositionLabel(log) }} + } @else { + — + } + + + Matches {{ log.matchCount || 0 }} @@ -84,7 +127,19 @@ Screened At - {{ log.screenedAt | date: 'medium' }} + + {{ log.screenedOn | date: 'medium' }} + + + + Review + + @if (isAlert(log)) { + + } + diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.scss b/src/app/reports/sanctions-logs/sanctions-logs.component.scss index 05c32eddb..88ee9f692 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.component.scss +++ b/src/app/reports/sanctions-logs/sanctions-logs.component.scss @@ -1,3 +1,11 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + .page-header { display: flex; align-items: center; @@ -66,6 +74,22 @@ background-color: #d32f2f; border-color: #d32f2f; } + + .chip-pending.active-filter { + background-color: #b45309; + border-color: #b45309; + } +} + +.rescreen-btn { + font-size: 13px; + border-radius: 20px; + margin-left: auto; + + .chip-icon { + margin-right: 4px; + font-size: 12px; + } } .result-badge { @@ -89,6 +113,25 @@ color: #c62828; } +/* Reviewed but not yet signed off by the Principal Officer — still blocking. */ +.badge-pending { + background: #fffbeb; + color: #b45309; +} + +.muted { + color: #9ca3af; +} + +.review-btn { + font-size: 12px; + border-radius: 16px; + + fa-icon { + margin-right: 4px; + } +} + .select-row { cursor: pointer; diff --git a/src/app/reports/sanctions-logs/sanctions-logs.component.ts b/src/app/reports/sanctions-logs/sanctions-logs.component.ts index 6328fbce7..bb2531c21 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.component.ts +++ b/src/app/reports/sanctions-logs/sanctions-logs.component.ts @@ -1,3 +1,11 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, MatSortHeader } from '@angular/material/sort'; @@ -17,10 +25,13 @@ import { import { MatChipsModule } from '@angular/material/chips'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { NgClass, DatePipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { SanctionsLogsService } from './sanctions-logs.service'; +import { SanctionsAlertReviewDialogComponent } from './sanctions-alert-review-dialog.component'; @Component({ selector: 'mifosx-sanctions-logs', @@ -51,19 +62,29 @@ import { SanctionsLogsService } from './sanctions-logs.service'; }) export class SanctionsLogsComponent implements OnInit { private sanctionsLogsService = inject(SanctionsLogsService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); displayedColumns: string[] = [ 'screenedName', 'screeningResult', + 'disposition', 'matchCount', 'screenedBy', - 'screenedAt' + 'screenedAt', + 'actions' ]; dataSource = new MatTableDataSource(); searchName = ''; activeFilter: string | null = null; + /** + * When set, the table shows the Principal Officer's alert queue (flagged screenings and their + * disposition) instead of the raw screening log. 'ALL' means every alert regardless of state. + */ + alertFilter: string | null = null; loading = false; + rescreening = false; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -74,6 +95,20 @@ export class SanctionsLogsComponent implements OnInit { loadLogs() { this.loading = true; + + // The alert queue is a different endpoint, not a filter on the log list: it carries the + // disposition columns the review dialog needs. + const request$ = this.alertFilter + ? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter) + : this.sanctionsLogsService.getLogs(this.buildLogFilters()); + + request$.subscribe({ + next: (data: any) => this.applyRows(data || []), + error: () => this.applyRows([]) + }); + } + + private buildLogFilters() { const filters: any = {}; if (this.activeFilter) { filters.result = this.activeFilter; @@ -81,35 +116,102 @@ export class SanctionsLogsComponent implements OnInit { if (this.searchName?.trim()) { filters.name = this.searchName.trim(); } - this.sanctionsLogsService.getLogs(filters).subscribe({ - next: (data: any) => { - this.dataSource = new MatTableDataSource(data || []); - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.loading = false; - }, - error: () => { - this.dataSource = new MatTableDataSource([]); - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.loading = false; - } - }); + return filters; + } + + private applyRows(rows: any[]) { + this.dataSource = new MatTableDataSource(rows); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.loading = false; } applySearch(value: string) { this.searchName = value; + // Name search only exists on the log endpoint, so leave the alert queue when searching. + this.alertFilter = null; this.loadLogs(); } filterByResult(result: string | null) { this.activeFilter = this.activeFilter === result ? null : result; + this.alertFilter = null; + this.loadLogs(); + } + + filterByAlert(status: string) { + this.alertFilter = this.alertFilter === status ? null : status; + this.activeFilter = null; this.loadLogs(); } clearFilters() { this.searchName = ''; this.activeFilter = null; + this.alertFilter = null; this.loadLogs(); } + + /** Only flagged rows carry a case to review. */ + isAlert(row: any): boolean { + return !!row?.disposition; + } + + dispositionLabel(row: any): string { + switch (row?.disposition) { + case 'PENDING_REVIEW': + return 'Pending Review'; + case 'FALSE_POSITIVE': + return row.approvedBy ? 'False Positive — Approved' : 'False Positive — Awaiting PO'; + case 'CONFIRMED_MATCH': + if (!row.approvedBy) { + return 'Confirmed — Awaiting PO'; + } + // An onboarding match has no client and therefore no accounts to freeze — saying + // "Frozen" there would claim an action that never happened. + return row.frozenAccountIds ? 'Confirmed — Frozen' : 'Confirmed — Blocked'; + default: + return '—'; + } + } + + dispositionClass(row: any): string { + if (row?.disposition === 'CONFIRMED_MATCH') return 'badge-flagged'; + if (row?.disposition === 'FALSE_POSITIVE') return row.approvedBy ? 'badge-clear' : 'badge-pending'; + if (row?.disposition === 'PENDING_REVIEW') return 'badge-pending'; + return ''; + } + + reviewAlert(row: any) { + this.dialog + .open(SanctionsAlertReviewDialogComponent, { width: '600px', data: { ...row }, autoFocus: false }) + .afterClosed() + .subscribe((changed: boolean) => { + if (changed) { + this.loadLogs(); + } + }); + } + + /** + * Screens every existing client against the currently loaded list snapshot. This is what + * detects a customer who was added to the UN list after they were onboarded — the only + * situation in which there are accounts to freeze. + */ + rescreenExistingClients() { + this.rescreening = true; + this.sanctionsLogsService.rescreenExistingClients().subscribe({ + next: (res: any) => { + this.rescreening = false; + this.snackBar.open(`Re-screening complete — ${res?.clientsFlagged ?? 0} client(s) flagged.`, 'Close', { + duration: 6000 + }); + this.filterByAlert('PENDING_REVIEW'); + }, + error: (err: any) => { + this.rescreening = false; + this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 }); + } + }); + } } diff --git a/src/app/reports/sanctions-logs/sanctions-logs.service.ts b/src/app/reports/sanctions-logs/sanctions-logs.service.ts index 4f16e5fdd..4419a27ea 100644 --- a/src/app/reports/sanctions-logs/sanctions-logs.service.ts +++ b/src/app/reports/sanctions-logs/sanctions-logs.service.ts @@ -1,3 +1,11 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; @@ -16,4 +24,39 @@ export class SanctionsLogsService { } return this.http.get('/sanctions/logs', { params }); } + + /** + * Flagged screenings and their disposition state — the Principal Officer's review queue. + * @param status PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH, or omitted for all alerts + */ + getAlerts(status?: string): Observable { + let params = new HttpParams(); + if (status) { + params = params.set('status', status); + } + return this.http.get('/sanctions/alerts', { params }); + } + + /** + * Principal Officer sign-off. Approving a false positive clears the name for onboarding; + * approving a confirmed match freezes debits on the client's active deposit accounts. + */ + approveAlert(logId: number): Observable { + return this.http.post(`/sanctions/alerts/${logId}/approve`, {}); + } + + /** Sends the alert back for a fresh assessment by overwriting its disposition. */ + recordDisposition(logId: number, disposition: string, reason: string): Observable { + return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason }); + } + + /** Records the FIU-IND STR reference and the date the nodal officer was informed. */ + recordReporting(logId: number, fiuStrReference: string, nodalOfficerInformedOn: string): Observable { + return this.http.post(`/sanctions/alerts/${logId}/report`, { fiuStrReference, nodalOfficerInformedOn }); + } + + /** Re-screens every existing client against the currently loaded list snapshot. */ + rescreenExistingClients(): Observable { + return this.http.post('/sanctions/rescreen', {}); + } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index a08b20b4a..543316953 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260811', - 'hash': 'f379b241' + 'version': '260814', + 'hash': '997e8920' }, 'allow_switching_backend_instance': true };