- 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.
571 lines
16 KiB
TypeScript
571 lines
16 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
@Component({
|
|
selector: 'mifosx-dip-verify-dialog',
|
|
template: `
|
|
<h2 mat-dialog-title>
|
|
<fa-icon icon="satellite-dish" class="dialog-title-icon"></fa-icon>
|
|
DIP Verification
|
|
</h2>
|
|
<mat-dialog-content>
|
|
@if (verifying) {
|
|
<mat-progress-bar mode="determinate" [value]="progress" color="primary"></mat-progress-bar>
|
|
<p class="progress-percent">{{ progress }}%</p>
|
|
<div class="verify-info">
|
|
<fa-icon icon="mobile-alt" class="info-icon"></fa-icon>
|
|
<p class="verify-msg">
|
|
Please wait while the system verifies the entered mobile number against the
|
|
<strong>Mobile Number Revocation List (MNRL)</strong> available on the
|
|
<strong>Digital Intelligence Platform (DIP)</strong> of the Department of Telecommunications (DoT),
|
|
Government of India.
|
|
</p>
|
|
</div>
|
|
<div class="warning-row">
|
|
<fa-icon icon="exclamation-triangle" class="warning-icon"></fa-icon>
|
|
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
|
|
</div>
|
|
}
|
|
<!--
|
|
An unreachable DoT service means the number was NOT checked. Reporting that as a green
|
|
"verification completed successfully — number is CLEAN" claimed a check that never ran and
|
|
stored the client as verified, so the outage is now shown for what it is.
|
|
-->
|
|
@if (!verifying && serviceUnavailable) {
|
|
<div class="verify-result unavailable">
|
|
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
|
|
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
|
|
<div class="unavailable-detail">
|
|
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
|
|
the service is reachable, or proceed with a recorded reason.
|
|
</div>
|
|
</div>
|
|
}
|
|
@if (!verifying && !suspected && !serviceUnavailable && !error) {
|
|
<div class="verify-result">
|
|
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
|
|
<p class="result-msg">Mobile number verified — Not found in MNRL revocation list.</p>
|
|
</div>
|
|
}
|
|
@if (!verifying && suspected) {
|
|
<div class="verify-result suspected">
|
|
<fa-icon icon="exclamation-circle" class="result-icon suspected-icon"></fa-icon>
|
|
<p class="result-msg suspected-msg">{{ resultMessage }}</p>
|
|
@if (suspectedReason) {
|
|
<div class="suspected-detail">
|
|
<p><strong>Status:</strong> {{ suspectedStatus }}</p>
|
|
<p><strong>Reason:</strong> {{ suspectedReason }}</p>
|
|
<p><strong>Reported by:</strong> {{ suspectedReportedBy }}</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
@if (!verifying && error) {
|
|
<div class="verify-result error">
|
|
<fa-icon icon="times-circle" class="result-icon error-icon"></fa-icon>
|
|
<p class="result-msg error-msg">{{ error }}</p>
|
|
</div>
|
|
}
|
|
|
|
<!--
|
|
A number turned away before is a standing signal. Without this the next officer screens
|
|
it as if it had no history and can wave through what a colleague already refused.
|
|
-->
|
|
@if (!verifying && priorRejection) {
|
|
<div class="prior-rejection">
|
|
<fa-icon icon="history" class="prior-icon"></fa-icon>
|
|
<div>
|
|
<strong>This number was rejected before.</strong>
|
|
<div class="prior-detail">
|
|
Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
|
|
status was {{ priorRejection.lookupStatus }}.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (!verifying && needsDecision && showReasonForm) {
|
|
<div class="reason-form">
|
|
<label class="reason-label">Why is this number acceptable despite the DIP/MNRL result?</label>
|
|
<textarea
|
|
class="reason-input"
|
|
rows="3"
|
|
[(ngModel)]="decisionReason"
|
|
placeholder="e.g. Number re-issued to the applicant on 02/2026; ownership confirmed against the operator's CAF and a fresh OTP."
|
|
></textarea>
|
|
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
|
|
Minimum 10 characters — this reason is kept on the lookup record.
|
|
</div>
|
|
@if (decisionError) {
|
|
<div class="reason-hint reason-error">{{ decisionError }}</div>
|
|
}
|
|
</div>
|
|
}
|
|
</mat-dialog-content>
|
|
<mat-dialog-actions align="end">
|
|
@if (!verifying && !needsDecision) {
|
|
<button mat-raised-button color="primary" (click)="close()">OK</button>
|
|
}
|
|
<!--
|
|
A flagged number is a decision, not a notification: the officer must either reject it or
|
|
say in writing why it is acceptable. Previously a single OK dismissed the warning and the
|
|
number was onboarded with nothing recorded.
|
|
-->
|
|
@if (!verifying && needsDecision && !showReasonForm) {
|
|
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject this Number</button>
|
|
<button mat-raised-button color="primary" (click)="startProceed()" [disabled]="saving">
|
|
Proceed with Reason
|
|
</button>
|
|
}
|
|
@if (!verifying && needsDecision && showReasonForm) {
|
|
<button mat-stroked-button (click)="cancelProceed()" [disabled]="saving">Back</button>
|
|
<button mat-raised-button color="primary" (click)="submitProceed()" [disabled]="saving">
|
|
{{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
|
|
</button>
|
|
}
|
|
</mat-dialog-actions>
|
|
`,
|
|
styles: [
|
|
`
|
|
:host {
|
|
display: block;
|
|
min-width: 450px;
|
|
}
|
|
|
|
h2[mat-dialog-title] {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 0;
|
|
padding: 16px 24px;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1074b9;
|
|
}
|
|
|
|
.dialog-title-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
mat-dialog-content {
|
|
padding: 16px 24px;
|
|
}
|
|
|
|
mat-progress-bar {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.progress-percent {
|
|
text-align: center;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: #1074b9;
|
|
margin: 4px 0 12px;
|
|
}
|
|
|
|
.verify-info {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
background: #e3f2fd;
|
|
border-radius: 8px;
|
|
margin-bottom: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.info-icon {
|
|
font-size: 24px;
|
|
color: #1074b9;
|
|
margin-top: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.verify-msg {
|
|
font-size: 14px;
|
|
color: #333;
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
}
|
|
|
|
.warning-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
background: #fff3e0;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.warning-icon {
|
|
font-size: 16px;
|
|
color: #e65100;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.warning-text {
|
|
font-size: 13px;
|
|
color: #e65100;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.verify-result {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 16px 0;
|
|
}
|
|
|
|
.result-icon {
|
|
font-size: 48px;
|
|
color: #388e3c;
|
|
}
|
|
|
|
.result-msg {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #388e3c;
|
|
margin: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.suspected-icon {
|
|
color: #e65100;
|
|
}
|
|
|
|
.suspected-msg {
|
|
color: #e65100;
|
|
}
|
|
|
|
.suspected-detail {
|
|
background: #fff3e0;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.suspected-detail p {
|
|
margin: 4px 0;
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
color: #333;
|
|
}
|
|
|
|
.success-detail {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: #e8f5e9;
|
|
border: 1px solid #a5d6a7;
|
|
border-radius: 8px;
|
|
padding: 10px 16px;
|
|
width: 100%;
|
|
font-size: 13px;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.success-detail-icon {
|
|
font-size: 16px;
|
|
color: #388e3c;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.error-icon {
|
|
color: #d32f2f;
|
|
}
|
|
|
|
.error-msg {
|
|
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,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class DipVerifyDialogComponent implements OnInit, OnDestroy {
|
|
private dialogRef = inject(MatDialogRef<DipVerifyDialogComponent>);
|
|
private data: { mobileNo: string } = inject(MAT_DIALOG_DATA);
|
|
private clientsService = inject(ClientsService);
|
|
|
|
verifying = true;
|
|
progress = 0;
|
|
suspected = false;
|
|
serviceUnavailable = false;
|
|
error: string | null = null;
|
|
resultMessage = '';
|
|
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;
|
|
private apiError: any = null;
|
|
private apiDone = false;
|
|
|
|
ngOnInit() {
|
|
this.dialogRef.disableClose = true;
|
|
|
|
// Animate progress bar over ~3 seconds (0 → 100)
|
|
this.intervalId = setInterval(() => {
|
|
this.progress += 2;
|
|
if (this.progress >= 100) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
this.tryShowResult();
|
|
}
|
|
}, 60);
|
|
|
|
// Fire the API call in parallel
|
|
this.subscription = this.clientsService.checkDipLookup(this.data.mobileNo).subscribe({
|
|
next: (res: any) => {
|
|
this.apiResponse = res;
|
|
this.apiDone = true;
|
|
this.tryShowResult();
|
|
},
|
|
error: (err: any) => {
|
|
this.apiError = err;
|
|
this.apiDone = true;
|
|
this.tryShowResult();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Show result only after both progress bar completes and API responds */
|
|
private tryShowResult() {
|
|
if (!this.apiDone || this.progress < 100) {
|
|
return;
|
|
}
|
|
|
|
this.verifying = false;
|
|
this.dialogRef.disableClose = false;
|
|
|
|
if (this.apiError) {
|
|
this.error =
|
|
this.apiError?.error?.message || this.apiError?.message || 'DIP verification failed. Please try again.';
|
|
return;
|
|
}
|
|
|
|
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.';
|
|
} else if (res?.data?.suspected) {
|
|
this.suspected = true;
|
|
this.resultMessage = res.message || `Mobile number is ${res.data.status} — found in DIP records`;
|
|
const detail = res.data.suspectedDetail;
|
|
if (detail) {
|
|
this.suspectedStatus = detail.dipStatus || res.data.status;
|
|
this.suspectedReason = detail.reason || '';
|
|
this.suspectedReportedBy = detail.reportedBy || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
if (this.subscription) {
|
|
this.subscription.unsubscribe();
|
|
}
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
}
|
|
}
|
|
|
|
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 && !this.serviceUnavailable;
|
|
this.dialogRef.close({
|
|
verified,
|
|
lookupLogId: this.apiResponse?.lookupLogId || null,
|
|
status: this.apiResponse?.data?.status || null,
|
|
timestamp: this.apiResponse?.timestamp || null
|
|
});
|
|
}
|
|
}
|