feat: add database export, welcome kit dialog, and DIP/sanctions updates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,14 @@ import { DipLookupsService } from './dip-lookups.service';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Client</span>
|
||||
<span class="detail-value">{{ row.clientId || 'Not onboarded' }}</span>
|
||||
<span class="detail-value">
|
||||
@if (row.clientId) {
|
||||
{{ row.clientName || 'Client #' + row.clientId }}
|
||||
<span class="detail-sub">{{ row.clientAccountNo || row.clientId }}</span>
|
||||
} @else {
|
||||
Not onboarded
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Looked Up</span>
|
||||
@@ -172,6 +179,13 @@ import { DipLookupsService } from './dip-lookups.service';
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -56,12 +56,61 @@
|
||||
<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) {
|
||||
@if (searchNumber || activeStatus || pendingOnly || activeSource) {
|
||||
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Separate question from the status chips above: not "what did the check say" but "when was it run". -->
|
||||
<div class="source-filter">
|
||||
<span class="source-filter__label">Checked during</span>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
mat-stroked-button
|
||||
[class.active-filter]="!activeSource"
|
||||
matTooltip="Show lookups from both client creation and re-screening"
|
||||
(click)="filterBySource(null)"
|
||||
>
|
||||
<fa-icon icon="list" class="chip-icon"></fa-icon> Both
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-onboarding"
|
||||
[class.active-filter]="activeSource === 'ONBOARDING'"
|
||||
matTooltip="Numbers checked while the client was being created"
|
||||
(click)="filterBySource('ONBOARDING')"
|
||||
>
|
||||
<fa-icon icon="user-plus" class="chip-icon"></fa-icon>
|
||||
Client Creation
|
||||
<span class="chip-count">{{ onboardingCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-rescreen"
|
||||
[class.active-filter]="activeSource === 'RESCREEN'"
|
||||
matTooltip="Existing clients swept by the periodic re-screening run"
|
||||
(click)="filterBySource('RESCREEN')"
|
||||
>
|
||||
<fa-icon icon="sync" class="chip-icon"></fa-icon>
|
||||
Re-screening
|
||||
<span class="chip-count">{{ rescreenCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
class="rescreen-btn"
|
||||
[disabled]="rescreening"
|
||||
matTooltip="Screen every existing client's mobile numbers against DIP/MNRL. Catches numbers reported for fraud or reassigned after the customer was onboarded."
|
||||
(click)="rescreenExistingClients()"
|
||||
>
|
||||
<fa-icon icon="sync" class="chip-icon"></fa-icon>
|
||||
{{ rescreening ? 'Re-screening…' : 'Re-screen Existing Clients' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
@@ -90,13 +139,28 @@
|
||||
</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>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Checked During</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="source-badge" [ngClass]="sourceClass(row)">
|
||||
<fa-icon [icon]="isRescreen(row) ? 'sync' : 'user-plus'" class="source-badge__icon"></fa-icon>
|
||||
{{ sourceLabel(row) }}
|
||||
</span>
|
||||
</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>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.clientId) {
|
||||
<!-- Linked, so the officer can go straight to the customer the flagged number belongs to. -->
|
||||
<a class="client-link" [routerLink]="['/clients', row.clientId, 'general']">
|
||||
{{ row.clientName || 'Client #' + row.clientId }}
|
||||
<span class="client-acct">{{ row.clientAccountNo || row.clientId }}</span>
|
||||
</a>
|
||||
} @else {
|
||||
<span class="muted">Not onboarded</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="lookedUpOn">
|
||||
|
||||
@@ -79,6 +79,106 @@
|
||||
background-color: #b45309;
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
.chip-onboarding.active-filter {
|
||||
background-color: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
.chip-rescreen.active-filter {
|
||||
background-color: #0f766e;
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0 0 0 / 8%);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.active-filter .chip-count {
|
||||
background-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Which screening run a row came from — a different question from the result status chips. */
|
||||
.source-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 14px;
|
||||
margin-left: 6px;
|
||||
border-left: 1px solid #dfe3ea;
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&__icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.source-onboarding {
|
||||
background-color: rgb(109 40 217 / 10%);
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
&.source-rescreen {
|
||||
background-color: rgb(15 118 110 / 10%);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
.client-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: #1074b9;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.client-acct {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.rescreen-btn {
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
margin-left: auto;
|
||||
|
||||
.chip-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
@@ -108,10 +208,6 @@
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
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';
|
||||
@@ -66,6 +67,7 @@ import { DipDecisionDialogComponent } from './dip-decision-dialog.component';
|
||||
export class DipLookupsComponent implements OnInit {
|
||||
private service = inject(DipLookupsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'mobileNumber',
|
||||
@@ -82,7 +84,16 @@ export class DipLookupsComponent implements OnInit {
|
||||
activeStatus: string | null = null;
|
||||
/** True while the table is showing the pending-decision queue rather than the full log. */
|
||||
pendingOnly = false;
|
||||
/**
|
||||
* Which screening run the rows came from: 'ONBOARDING' for checks made while the client was being created,
|
||||
* 'RESCREEN' for the periodic sweep over existing clients, null for both. Filtered here rather than on the
|
||||
* API because the log endpoint only accepts a status filter.
|
||||
*/
|
||||
activeSource: 'ONBOARDING' | 'RESCREEN' | null = null;
|
||||
loading = false;
|
||||
rescreening = false;
|
||||
/** Full result set, kept so the source filter can be applied without another round trip. */
|
||||
private allRows: any[] = [];
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
@@ -107,10 +118,50 @@ export class DipLookupsComponent implements OnInit {
|
||||
}
|
||||
|
||||
private applyRows(rows: any[]) {
|
||||
this.allRows = rows;
|
||||
this.refreshTable();
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/** Applies the source filter over the rows already fetched. */
|
||||
private refreshTable() {
|
||||
const rows = this.activeSource
|
||||
? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row)))
|
||||
: this.allRows;
|
||||
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was
|
||||
* looked up as the number was typed during client creation.
|
||||
*/
|
||||
isRescreen(row: any): boolean {
|
||||
return (row?.lookupSource || '').toUpperCase().includes('RESCREEN');
|
||||
}
|
||||
|
||||
sourceLabel(row: any): string {
|
||||
return this.isRescreen(row) ? 'Re-screening' : 'Client Creation';
|
||||
}
|
||||
|
||||
sourceClass(row: any): string {
|
||||
return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding';
|
||||
}
|
||||
|
||||
filterBySource(source: 'ONBOARDING' | 'RESCREEN' | null) {
|
||||
this.activeSource = this.activeSource === source ? null : source;
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
/** Row counts behind each source chip, so the split is visible before filtering. */
|
||||
get onboardingCount(): number {
|
||||
return this.allRows.filter((row) => !this.isRescreen(row)).length;
|
||||
}
|
||||
|
||||
get rescreenCount(): number {
|
||||
return this.allRows.filter((row) => this.isRescreen(row)).length;
|
||||
}
|
||||
|
||||
applySearch(value: string) {
|
||||
@@ -135,6 +186,7 @@ export class DipLookupsComponent implements OnInit {
|
||||
this.searchNumber = '';
|
||||
this.activeStatus = null;
|
||||
this.pendingOnly = false;
|
||||
this.activeSource = null;
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -165,6 +217,35 @@ export class DipLookupsComponent implements OnInit {
|
||||
return flagged && !row?.overrideDecision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screens all existing clients' numbers. A customer onboarded with a clean number can have it
|
||||
* reported for fraud or reassigned later, and nothing else in the system would ever notice.
|
||||
*/
|
||||
rescreenExistingClients() {
|
||||
this.rescreening = true;
|
||||
this.service.rescreenExistingClients().subscribe({
|
||||
next: (res: any) => {
|
||||
this.rescreening = false;
|
||||
// Report what was scanned as well as what was flagged: "0 flagged" out of 0 clients means
|
||||
// nothing was checked, which is a very different situation from 0 hits out of 20.
|
||||
const flagged = res?.numbersFlagged ?? 0;
|
||||
const scanned = res?.clientsScanned ?? 0;
|
||||
this.snackBar.open(
|
||||
`Re-screening complete — ${flagged} number(s) flagged across ${scanned} active client(s).`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.pendingOnly = true;
|
||||
this.activeStatus = null;
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.rescreening = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Re-screening failed.', 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
review(row: any) {
|
||||
this.dialog
|
||||
.open(DipDecisionDialogComponent, { width: '560px', data: { ...row }, autoFocus: false })
|
||||
|
||||
@@ -35,4 +35,13 @@ export class DipLookupsService {
|
||||
recordDecision(logId: number, decision: string, reason: string): Observable<any> {
|
||||
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Screens every active client's mobile numbers against DIP/MNRL. Onboarding-time screening
|
||||
* only sees a number as it is typed, so this is what catches numbers flagged or reassigned
|
||||
* after the customer was onboarded.
|
||||
*/
|
||||
rescreenExistingClients(): Observable<any> {
|
||||
return this.http.post('/lookup/rescreen', {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,14 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Client</span>
|
||||
<span class="detail-value">{{ alert.clientId || 'Not yet onboarded' }}</span>
|
||||
<span class="detail-value">
|
||||
@if (alert.clientId) {
|
||||
{{ alert.clientName || 'Client #' + alert.clientId }}
|
||||
<span class="detail-sub">{{ alert.clientAccountNo || alert.clientId }}</span>
|
||||
} @else {
|
||||
Not yet onboarded
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Screened On</span>
|
||||
@@ -59,10 +66,42 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
|
||||
<div class="section-title">Reviewing Officer's Assessment</div>
|
||||
@if (alert.disposition === 'PENDING_REVIEW') {
|
||||
<!--
|
||||
An alert raised by the periodic sweep has no onboarding flow behind it, so this dialog is
|
||||
the only place it can ever be assessed. Without the form below such an alert was
|
||||
permanently stuck: it told the reader to wait for a branch officer who does not exist.
|
||||
-->
|
||||
<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.
|
||||
Not yet reviewed. Compare the customer's identifiers against the listed particulars, then record the outcome
|
||||
below.
|
||||
</div>
|
||||
@if (alert.matchedNames) {
|
||||
<div class="matched-names">
|
||||
<span class="matched-names__label">Listed as</span>
|
||||
{{ alert.matchedNames }}
|
||||
</div>
|
||||
}
|
||||
@if (showAssessForm) {
|
||||
<div class="reason-form">
|
||||
<label class="field-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="field-input"
|
||||
rows="3"
|
||||
[(ngModel)]="assessmentReason"
|
||||
placeholder="e.g. Customer DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; no identifier corroborates the listing."
|
||||
></textarea>
|
||||
<div class="field-hint" [class.field-error]="assessTouched && !assessmentValid">
|
||||
Minimum 10 characters — this text is the compliance record.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="assessment-card" [class.assessment-confirmed]="alert.disposition === 'CONFIRMED_MATCH'">
|
||||
<div class="assessment-badge">{{ dispositionLabel }}</div>
|
||||
@@ -81,6 +120,18 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
<span> · Debits frozen on account(s) {{ alert.frozenAccountIds }}</span>
|
||||
}
|
||||
</div>
|
||||
<!--
|
||||
An empty freeze is a legitimate outcome, not a failure — a client with no deposit account
|
||||
has no balance to restrict. Saying so explicitly stops it reading as a freeze that
|
||||
silently did not happen.
|
||||
-->
|
||||
@if (alert.disposition === 'CONFIRMED_MATCH' && !alert.frozenAccountIds) {
|
||||
<div class="no-freeze-note">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
No deposit account to freeze for this client. Onboarding and lending remain blocked, and the reporting
|
||||
obligation below still applies.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (alert.disposition === 'CONFIRMED_MATCH' && alert.approvedBy) {
|
||||
@@ -112,6 +163,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button (click)="close()" [disabled]="saving">Close</button>
|
||||
@if (needsAssessment && !showAssessForm) {
|
||||
<button mat-stroked-button (click)="startAssessment('FALSE_POSITIVE')" [disabled]="saving">
|
||||
Not the Same Person
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="startAssessment('CONFIRMED_MATCH')" [disabled]="saving">
|
||||
Confirm Match
|
||||
</button>
|
||||
}
|
||||
@if (needsAssessment && showAssessForm) {
|
||||
<button mat-stroked-button (click)="cancelAssessment()" [disabled]="saving">Back</button>
|
||||
<button mat-raised-button color="primary" (click)="submitAssessment()" [disabled]="saving">
|
||||
{{ saving ? 'Saving…' : 'Save Assessment' }}
|
||||
</button>
|
||||
}
|
||||
@if (canApprove) {
|
||||
<button mat-raised-button color="primary" (click)="approve()" [disabled]="saving">
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
@@ -180,6 +245,13 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -189,6 +261,34 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
|
||||
.matched-names {
|
||||
font-size: 12px;
|
||||
color: #7f1d1d;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.matched-names__label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.reason-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pending-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -285,6 +385,16 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea.field-input {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 8px;
|
||||
@@ -304,6 +414,20 @@ import { SanctionsLogsService } from './sanctions-logs.service';
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-freeze-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -339,6 +463,21 @@ export class SanctionsAlertReviewDialogComponent {
|
||||
saving = false;
|
||||
errorMessage = '';
|
||||
savedMessage = '';
|
||||
|
||||
/** Assessment capture for alerts that never passed through an onboarding flow. */
|
||||
showAssessForm = false;
|
||||
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
|
||||
assessmentReason = '';
|
||||
assessTouched = false;
|
||||
|
||||
/** An unreviewed alert has to be assessable here — nothing else will ever reach it. */
|
||||
get needsAssessment(): boolean {
|
||||
return this.alert?.disposition === 'PENDING_REVIEW';
|
||||
}
|
||||
|
||||
get assessmentValid(): boolean {
|
||||
return this.assessmentReason.trim().length >= 10;
|
||||
}
|
||||
/** Set when anything was persisted, so the caller knows to refresh the table. */
|
||||
private changed = false;
|
||||
|
||||
@@ -364,6 +503,44 @@ export class SanctionsAlertReviewDialogComponent {
|
||||
: 'Approve — Confirm & Freeze';
|
||||
}
|
||||
|
||||
startAssessment(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
|
||||
this.pendingDisposition = disposition;
|
||||
this.showAssessForm = true;
|
||||
this.assessTouched = false;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
cancelAssessment() {
|
||||
this.showAssessForm = false;
|
||||
this.pendingDisposition = null;
|
||||
this.assessmentReason = '';
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
submitAssessment() {
|
||||
this.assessTouched = true;
|
||||
if (!this.assessmentValid || !this.pendingDisposition) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
this.service.recordDisposition(this.alert.id, this.pendingDisposition, this.assessmentReason.trim()).subscribe({
|
||||
next: (updated: any) => {
|
||||
this.saving = false;
|
||||
this.changed = true;
|
||||
this.alert = updated || this.alert;
|
||||
this.showAssessForm = false;
|
||||
// Deliberately not auto-approved: the officer recording the assessment cannot also be the
|
||||
// one who signs it off, so a second user still has to reopen this and approve.
|
||||
this.savedMessage = 'Assessment recorded. A different officer must now approve it.';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
this.errorMessage = this.extractMessage(err, 'Could not save the assessment.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
@@ -47,9 +47,24 @@
|
||||
mat-stroked-button
|
||||
[class.active-filter]="alertFilter === 'PENDING_REVIEW'"
|
||||
class="chip-pending"
|
||||
matTooltip="Flagged screenings the reviewing officer has not assessed yet."
|
||||
(click)="filterByAlert('PENDING_REVIEW')"
|
||||
>
|
||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Pending Review
|
||||
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Assessment
|
||||
</button>
|
||||
<!--
|
||||
The Principal Officer's own queue. Without it an assessed alert dropped out of "Pending
|
||||
Review" and the only way to reach it was to scan every alert — so the freeze it was waiting
|
||||
on simply never happened.
|
||||
-->
|
||||
<button
|
||||
mat-stroked-button
|
||||
[class.active-filter]="alertFilter === 'AWAITING_APPROVAL'"
|
||||
class="chip-approval"
|
||||
matTooltip="Assessed by an officer and waiting for Principal Officer approval. Approving a confirmed match freezes the client's accounts."
|
||||
(click)="filterByAlert('AWAITING_APPROVAL')"
|
||||
>
|
||||
<fa-icon icon="user-shield" class="chip-icon"></fa-icon> Awaiting Approval
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
@@ -59,13 +74,50 @@
|
||||
>
|
||||
<fa-icon icon="folder-open" class="chip-icon"></fa-icon> All Alerts
|
||||
</button>
|
||||
@if (searchName || activeFilter || alertFilter) {
|
||||
@if (searchName || activeFilter || alertFilter || activeSource) {
|
||||
<button mat-icon-button color="warn" matTooltip="Clear all filters" (click)="clearFilters()">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Separate question from the result/alert chips above: not "what did the screening say" but "when was it run". -->
|
||||
<div class="source-filter">
|
||||
<span class="source-filter__label">Screened during</span>
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
mat-stroked-button
|
||||
[class.active-filter]="!activeSource"
|
||||
matTooltip="Show screenings from both client creation and re-screening"
|
||||
(click)="filterBySource(null)"
|
||||
>
|
||||
<fa-icon icon="list" class="chip-icon"></fa-icon> Both
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-onboarding"
|
||||
[class.active-filter]="activeSource === 'CLIENT_CREATION'"
|
||||
matTooltip="Names screened while the client was being created"
|
||||
(click)="filterBySource('CLIENT_CREATION')"
|
||||
>
|
||||
<fa-icon icon="user-plus" class="chip-icon"></fa-icon>
|
||||
Client Creation
|
||||
<span class="chip-count">{{ creationCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="chip-rescreen"
|
||||
[class.active-filter]="activeSource === 'RESCREEN'"
|
||||
matTooltip="Existing clients swept by the periodic re-screening run"
|
||||
(click)="filterBySource('RESCREEN')"
|
||||
>
|
||||
<fa-icon icon="sync" class="chip-icon"></fa-icon>
|
||||
Re-screening
|
||||
<span class="chip-count">{{ rescreenCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
@@ -120,6 +172,31 @@
|
||||
<td mat-cell *matCellDef="let log">{{ log.matchCount || 0 }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="screeningSource">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened During</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
<span class="source-badge" [ngClass]="sourceClass(log)">
|
||||
<fa-icon [icon]="isRescreen(log) ? 'sync' : 'user-plus'" class="source-badge__icon"></fa-icon>
|
||||
{{ sourceLabel(log) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="client">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
@if (log.clientId) {
|
||||
<!-- Linked so the officer can go straight to the customer the alert belongs to. -->
|
||||
<a class="client-link" [routerLink]="['/clients', log.clientId, 'general']">
|
||||
{{ log.clientName || 'Client #' + log.clientId }}
|
||||
<span class="client-acct">{{ log.clientAccountNo || log.clientId }}</span>
|
||||
</a>
|
||||
} @else {
|
||||
<span class="muted">Not onboarded</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="screenedBy">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Screened By</th>
|
||||
<td mat-cell *matCellDef="let log">{{ log.screenedBy }}</td>
|
||||
@@ -135,8 +212,16 @@
|
||||
<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
|
||||
<!-- Labelled by what the row actually needs next, so the officer is not left guessing
|
||||
whether opening it will ask them to assess or to approve. -->
|
||||
<button
|
||||
mat-stroked-button
|
||||
[color]="isAwaitingApproval(log) ? 'warn' : 'primary'"
|
||||
class="review-btn"
|
||||
(click)="reviewAlert(log)"
|
||||
>
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
{{ isAwaitingApproval(log) ? 'Approve' : log.disposition === 'PENDING_REVIEW' ? 'Assess' : 'View' }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -79,6 +79,79 @@
|
||||
background-color: #b45309;
|
||||
border-color: #b45309;
|
||||
}
|
||||
|
||||
.chip-approval.active-filter {
|
||||
background-color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* Indigo rather than the violet used by "Awaiting Approval", so the two chip groups stay distinguishable. */
|
||||
.chip-onboarding.active-filter {
|
||||
background-color: #3730a3;
|
||||
border-color: #3730a3;
|
||||
}
|
||||
|
||||
.chip-rescreen.active-filter {
|
||||
background-color: #0f766e;
|
||||
border-color: #0f766e;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0 0 0 / 8%);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.active-filter .chip-count {
|
||||
background-color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Which screening run a row came from — a different question from the result/alert chips. */
|
||||
.source-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 14px;
|
||||
margin-left: 6px;
|
||||
border-left: 1px solid #dfe3ea;
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&__icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.source-onboarding {
|
||||
background-color: rgb(55 48 163 / 10%);
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
&.source-rescreen {
|
||||
background-color: rgb(15 118 110 / 10%);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
.rescreen-btn {
|
||||
@@ -123,6 +196,24 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.client-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: #1074b9;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.client-acct {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -70,6 +70,8 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
'screeningResult',
|
||||
'disposition',
|
||||
'matchCount',
|
||||
'screeningSource',
|
||||
'client',
|
||||
'screenedBy',
|
||||
'screenedAt',
|
||||
'actions'
|
||||
@@ -83,8 +85,16 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
* disposition) instead of the raw screening log. 'ALL' means every alert regardless of state.
|
||||
*/
|
||||
alertFilter: string | null = null;
|
||||
/**
|
||||
* Which screening run the rows came from: 'CLIENT_CREATION' for names screened while the client was being
|
||||
* created, 'RESCREEN' for the periodic sweep over existing clients, null for both. Applied in the browser
|
||||
* because neither the log nor the alert endpoint accepts a source filter.
|
||||
*/
|
||||
activeSource: 'CLIENT_CREATION' | 'RESCREEN' | null = null;
|
||||
loading = false;
|
||||
rescreening = false;
|
||||
/** Full result set, kept so the source filter can be applied without another round trip. */
|
||||
private allRows: any[] = [];
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
@@ -98,16 +108,38 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
|
||||
// The alert queue is a different endpoint, not a filter on the log list: it carries the
|
||||
// disposition columns the review dialog needs.
|
||||
// AWAITING_APPROVAL is a client-side view over all alerts rather than a disposition value:
|
||||
// an alert waiting on the Principal Officer is one that HAS been assessed but not signed off,
|
||||
// so it is identified by approvedBy being empty, not by its disposition.
|
||||
const serverFilter =
|
||||
this.alertFilter === 'ALL' || this.alertFilter === 'AWAITING_APPROVAL' ? undefined : this.alertFilter;
|
||||
|
||||
const request$ = this.alertFilter
|
||||
? this.sanctionsLogsService.getAlerts(this.alertFilter === 'ALL' ? undefined : this.alertFilter)
|
||||
? this.sanctionsLogsService.getAlerts(serverFilter)
|
||||
: this.sanctionsLogsService.getLogs(this.buildLogFilters());
|
||||
|
||||
request$.subscribe({
|
||||
next: (data: any) => this.applyRows(data || []),
|
||||
next: (data: any) => this.applyRows(this.postFilter(data || [])),
|
||||
error: () => this.applyRows([])
|
||||
});
|
||||
}
|
||||
|
||||
/** Narrows the fetched alerts for views the API has no dedicated filter for. */
|
||||
private postFilter(rows: any[]): any[] {
|
||||
if (this.alertFilter !== 'AWAITING_APPROVAL') {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => this.isAwaitingApproval(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the clerk has recorded an assessment and the Principal Officer has not yet approved
|
||||
* it. This is the state in which the freeze is pending and nothing happens until someone acts.
|
||||
*/
|
||||
isAwaitingApproval(row: any): boolean {
|
||||
return !!row?.disposition && row.disposition !== 'PENDING_REVIEW' && !row.approvedBy;
|
||||
}
|
||||
|
||||
private buildLogFilters() {
|
||||
const filters: any = {};
|
||||
if (this.activeFilter) {
|
||||
@@ -120,10 +152,50 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
}
|
||||
|
||||
private applyRows(rows: any[]) {
|
||||
this.allRows = rows;
|
||||
this.refreshTable();
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/** Applies the source filter over the rows already fetched. */
|
||||
private refreshTable() {
|
||||
const rows = this.activeSource
|
||||
? this.allRows.filter((row) => (this.activeSource === 'RESCREEN' ? this.isRescreen(row) : !this.isRescreen(row)))
|
||||
: this.allRows;
|
||||
|
||||
this.dataSource = new MatTableDataSource(rows);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row came from the periodic sweep when the backend stamped it PERIODIC_RESCREEN; everything else was
|
||||
* screened as the name was entered during client creation.
|
||||
*/
|
||||
isRescreen(row: any): boolean {
|
||||
return (row?.screeningSource || '').toUpperCase().includes('RESCREEN');
|
||||
}
|
||||
|
||||
sourceLabel(row: any): string {
|
||||
return this.isRescreen(row) ? 'Re-screening' : 'Client Creation';
|
||||
}
|
||||
|
||||
sourceClass(row: any): string {
|
||||
return this.isRescreen(row) ? 'source-rescreen' : 'source-onboarding';
|
||||
}
|
||||
|
||||
filterBySource(source: 'CLIENT_CREATION' | 'RESCREEN' | null) {
|
||||
this.activeSource = this.activeSource === source ? null : source;
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
/** Row counts behind each source chip, so the split is visible before filtering. */
|
||||
get creationCount(): number {
|
||||
return this.allRows.filter((row) => !this.isRescreen(row)).length;
|
||||
}
|
||||
|
||||
get rescreenCount(): number {
|
||||
return this.allRows.filter((row) => this.isRescreen(row)).length;
|
||||
}
|
||||
|
||||
applySearch(value: string) {
|
||||
@@ -149,6 +221,7 @@ export class SanctionsLogsComponent implements OnInit {
|
||||
this.searchName = '';
|
||||
this.activeFilter = null;
|
||||
this.alertFilter = null;
|
||||
this.activeSource = null;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
|
||||
@@ -59,4 +59,18 @@ export class SanctionsLogsService {
|
||||
rescreenExistingClients(): Observable<any> {
|
||||
return this.http.post('/sanctions/rescreen', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed matches owing an STR to FIU-IND — the sanctions stream on the FIU screen, kept
|
||||
* apart from transaction-driven AML alerts.
|
||||
*
|
||||
* @param status PENDING_FILING | FILED, or omitted for all
|
||||
*/
|
||||
getStrCases(status?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
return this.http.get('/sanctions/str-cases', { params });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user