feat: add database export, welcome kit dialog, and DIP/sanctions updates
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:52:28 +05:30
co-authored by Claude Opus 5
parent c18219fce5
commit 7ef75c902d
33 changed files with 3042 additions and 208 deletions
@@ -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> &middot; 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 });
}
}