Menu

Change language

GitHub repo

Compliance Sanctions Screener

blocklegal

AML/KYC OFAC, Politically Exposed Persons (PEP), and global sanctions list screening workbench with fuzzy match threshold tuning, real-time entity inspection modal, and prohibited match enforcement.

Installation

$npx uipkge-ng@latest add compliance-sanctions-screener
Manifest:https://uipkge.dev/r/angular/compliance-sanctions-screener.jsonInstalls to:src/app/components/blocks/compliance-sanctions-screener/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SanctionAttributeComparison
interface SanctionAttributeComparison {
  field: string
  submittedValue: string
  watchlistValue: string
  matchType: 'exact' | 'fuzzy' | 'partial' | 'clean' | 'unmatched'
  score: number
  note?: string
}
SanctionRecord
interface SanctionRecord {
  id: string
  entityName: string
  originalScript?: string
  entityType: EntityType
  matchScore: number
  matchStatus: MatchStatus
  sanctionList: string
  program: string
  matchedAttributesSummary: string
  country: string
  jurisdiction: string
  dateOfBirthOrIncorporation: string
  nationalIdOrLei: string
  address: string
  aliases: string[]
  pepLevel?: string
  remarks?: string
  lastScreened: string
  attributeComparisons: SanctionAttributeComparison[]
}
NotificationBanner
interface NotificationBanner {
  message: string
  type: 'success' | 'warning' | 'destructive' | 'info'
}

Files installed (5)

  • src/app/components/blocks/compliance-sanctions-screener/compliance-sanctions-screener.component.ts36 kB
    // AML/KYC OFAC, Politically Exposed Persons (PEP), and global sanctions list screening
    // workbench with fuzzy match threshold tuning, real-time entity inspection modal, and
    // prohibited match enforcement. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      AlertTriangle,
      Ban,
      Building2,
      Check,
      CheckCircle2,
      Eye,
      FileSearch,
      Globe,
      Info,
      LucideAngularModule,
      RefreshCw,
      RotateCcw,
      Search,
      ShieldAlert,
      ShieldCheck,
      ShieldX,
      Sliders,
      User,
      Users,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import type { EntityType, SanctionRecord } from './compliance-sanctions-types'
    import { defaultSanctionRecords } from './compliance-sanctions-data'
    import { UiSanctionsInvestigationDialogComponent } from './sanctions-investigation-dialog.component'
    
    export type { EntityType, SanctionRecord }
    
    function cloneRecords(records: SanctionRecord[]): SanctionRecord[] {
      return JSON.parse(JSON.stringify(records)) as SanctionRecord[]
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-compliance-sanctions-screener, [ui-compliance-sanctions-screener]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiInputComponent,
        UiSanctionsInvestigationDialogComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSliderComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="compliance-sanctions-screener" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
            <div>
              <div class="flex flex-wrap items-center gap-2.5">
                <h1 class="text-xl font-bold tracking-tight sm:text-2xl">AML / OFAC Sanctions &amp; PEP Screening</h1>
                <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 py-0.5">
                  <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                  <span class="font-medium">OFAC SDN, EU Consolidated, UK HMT, UN Sanctions · Synced 10m ago</span>
                </span>
              </div>
              <p class="text-muted-foreground mt-1 text-xs sm:text-sm">
                Automated identity verification, Politically Exposed Persons (PEP), and multilateral sanctions screening
                workbench.
              </p>
            </div>
    
            <div class="flex shrink-0 items-center gap-2">
              <button
                ui-button
                variant="default"
                size="sm"
                [disabled]="isBatchScreening()"
                class="cursor-pointer gap-2 shadow-xs"
                (click)="handleRunBatchScreening()"
              >
                <lucide-icon [img]="RefreshCw" [class]="batchIconClass" />
                <span>{{ isBatchScreening() ? 'Screening 1,420 Records...' : 'Run Batch Screening' }}</span>
              </button>
            </div>
          </div>
    
          <!-- Notification Toast / Banner -->
          @if (notificationBanner(); as banner) {
            <div [class]="bannerClass(banner.type)">
              <div class="flex min-w-0 items-center gap-2">
                @switch (banner.type) {
                  @case ('success') {
                    <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                  }
                  @case ('warning') {
                    <lucide-icon [img]="AlertTriangle" class="size-4 shrink-0" />
                  }
                  @case ('destructive') {
                    <lucide-icon [img]="Ban" class="size-4 shrink-0" />
                  }
                  @default {
                    <lucide-icon [img]="Info" class="size-4 shrink-0" />
                  }
                }
                <span>{{ banner.message }}</span>
              </div>
              <button
                aria-label="Dismiss notification"
                type="button"
                class="rounded-md p-1 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
                (click)="notificationBanner.set(null)"
              >
                <lucide-icon [img]="X" class="size-3.5" />
              </button>
            </div>
          }
    
          <!-- 4 Screening Telemetry Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <!-- Card 1: Total Screened -->
            <div ui-card class="border-border/80 border shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase"
                    >Total Screened Today</span
                  >
                  <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="Users" class="size-4" />
                  </div>
                </div>
                <div class="mt-3 flex items-baseline gap-2">
                  <span class="text-2xl font-bold tracking-tight tabular-nums">{{
                    totalScreenedCount.toLocaleString()
                  }}</span>
                  <span class="text-muted-foreground text-xs font-medium">Entities</span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">
                  <span class="text-success font-medium">+14.2%</span> vs yesterday · 38 automated batches
                </p>
              </div>
            </div>
    
            <!-- Card 2: Clear / No Matches -->
            <div ui-card class="border-border/80 border shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Clear / No Matches</span>
                  <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="ShieldCheck" class="size-4" />
                  </div>
                </div>
                <div class="mt-3 flex items-baseline gap-2">
                  <span class="text-success text-2xl font-bold tracking-tight tabular-nums">
                    {{ clearedCount().toLocaleString() }}
                  </span>
                  <span class="text-success text-xs font-semibold tabular-nums">Cleared · 99.2%</span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">Low Risk · 0 PEP matches · 0 adverse media</p>
              </div>
            </div>
    
            <!-- Card 3: Potential Matches Flagged -->
            <div ui-card class="border-border/80 border shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase"
                    >Potential Matches Flagged</span
                  >
                  <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="AlertTriangle" class="size-4" />
                  </div>
                </div>
                <div class="mt-3 flex items-baseline gap-2">
                  <span class="text-warning text-2xl font-bold tracking-tight tabular-nums">
                    {{ flaggedCount() }}
                  </span>
                  <span class="text-warning text-xs font-semibold">Pending Review</span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">Action required · SLA &lt;2h · 1 high confidence</p>
              </div>
            </div>
    
            <!-- Card 4: Confirmed Prohibited Matches -->
            <div ui-card class="border-border/80 border shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase"
                    >Confirmed Prohibited Matches</span
                  >
                  <div class="bg-destructive/10 text-destructive flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="Ban" class="size-4" />
                  </div>
                </div>
                <div class="mt-3 flex items-baseline gap-2">
                  <span
                    class="text-2xl font-bold tracking-tight tabular-nums"
                    [class]="blockedCount() > 0 ? 'text-destructive' : 'text-foreground'"
                  >
                    {{ blockedCount() }}
                  </span>
                  <span class="text-muted-foreground text-xs font-semibold">Blocked</span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">Asset freeze locked · FinCEN SAR ready</p>
              </div>
            </div>
          </div>
    
          <!-- Real-Time Entity Search Bar & Fuzzy Threshold Workbench -->
          <div ui-card class="border-border/80 bg-card border shadow-xs">
            <div ui-card-content class="space-y-4 p-4 sm:p-5">
              <div class="grid grid-cols-1 items-end gap-4 md:grid-cols-12">
                <!-- Search Input -->
                <div class="space-y-1.5 md:col-span-4">
                  <label for="entity-search-input" class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                    <lucide-icon [img]="Search" class="text-muted-foreground size-3.5" />
                    <span>Search Individual / Entity Name</span>
                  </label>
                  <div class="relative">
                    <ui-input
                      id="entity-search-input"
                      [value]="searchQuery()"
                      (valueChange)="searchQuery.set($event)"
                      type="text"
                      placeholder="Search name, alias, ID or passport..."
                      class="bg-background pl-8 text-xs sm:text-sm"
                    />
                    <lucide-icon
                      [img]="Search"
                      class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                    />
                    @if (searchQuery()) {
                      <button
                        aria-label="Clear search"
                        type="button"
                        class="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 -translate-y-1/2"
                        (click)="searchQuery.set('')"
                      >
                        <lucide-icon [img]="X" class="size-3.5" />
                      </button>
                    }
                  </div>
                </div>
    
                <!-- Country Filter Dropdown -->
                <div class="space-y-1.5 md:col-span-3">
                  <span class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                    <lucide-icon [img]="Globe" class="text-muted-foreground size-3.5" />
                    <span>Country / Domicile Filter</span>
                  </span>
                  <ui-select [value]="selectedCountry()" (valueChange)="selectedCountry.set($event)">
                    <button ui-select-trigger class="bg-background w-full text-xs sm:text-sm">
                      <ui-select-value placeholder="All Countries" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="all">All Countries</ui-select-item>
                      <ui-select-item value="Russia">Russia (Russian Federation)</ui-select-item>
                      <ui-select-item value="United States">United States (USA)</ui-select-item>
                      <ui-select-item value="Cyprus">Cyprus (EU)</ui-select-item>
                      <ui-select-item value="United Kingdom">United Kingdom (UK)</ui-select-item>
                      <ui-select-item value="Switzerland">Switzerland (CH)</ui-select-item>
                      <ui-select-item value="United Arab Emirates">United Arab Emirates (UAE)</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                </div>
    
                <!-- Entity Type Pills -->
                <div class="space-y-1.5 md:col-span-2">
                  <span class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                    <lucide-icon [img]="Building2" class="text-muted-foreground size-3.5" />
                    <span>Entity Type</span>
                  </span>
                  <div class="bg-muted border-input flex h-9 items-center gap-1 rounded-md border p-1">
                    <button type="button" [class]="entityPillClass('all')" (click)="selectedEntityType.set('all')">
                      All
                    </button>
                    <button
                      type="button"
                      [class]="entityPillClass('individual')"
                      (click)="selectedEntityType.set('individual')"
                    >
                      Indiv.
                    </button>
                    <button
                      type="button"
                      [class]="entityPillClass('corporate')"
                      (click)="selectedEntityType.set('corporate')"
                    >
                      Corp.
                    </button>
                  </div>
                </div>
    
                <!-- Match Threshold Slider -->
                <div class="space-y-1.5 md:col-span-3">
                  <div class="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
                    <span class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                      <lucide-icon [img]="Sliders" class="text-muted-foreground size-3.5" />
                      <span>Match Threshold</span>
                    </span>
                    <span ui-badge variant="secondary" class="text-xs font-semibold tabular-nums">
                      {{ currentThreshold() }}% Fuzzy Match
                    </span>
                  </div>
                  <div class="pt-1.5">
                    <ui-slider
                      [value]="thresholdValue()"
                      (valueChange)="onThresholdChange($event)"
                      [min]="50"
                      [max]="100"
                      [step]="1"
                      class="w-full"
                    />
                  </div>
                </div>
              </div>
    
              <!-- Filter helper status bar -->
              <div
                class="border-border/60 text-muted-foreground flex flex-wrap items-center justify-between gap-2 border-t pt-2 text-xs"
              >
                <div class="flex min-w-0 items-center gap-2">
                  <span
                    >Showing <strong class="text-foreground tabular-nums">{{ filteredRecords().length }}</strong> of
                    <strong class="text-foreground tabular-nums">{{ records().length }}</strong> screening audit
                    records</span
                  >
                  @if (searchQuery() || selectedCountry() !== 'all' || selectedEntityType() !== 'all') {
                    <span class="text-muted-foreground/60">· Filters applied</span>
                  }
                </div>
                <div class="flex min-w-0 items-center gap-2">
                  @if (
                    searchQuery() ||
                    selectedCountry() !== 'all' ||
                    selectedEntityType() !== 'all' ||
                    currentThreshold() !== 85
                  ) {
                    <button
                      ui-button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground h-7 gap-1 text-xs"
                      (click)="handleResetFilters()"
                    >
                      <lucide-icon [img]="RotateCcw" class="size-3" />
                      <span>Reset Filters</span>
                    </button>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <!-- Screening Match Results Table -->
          <div ui-card class="border-border/80 overflow-hidden border shadow-xs">
            <div class="border-border/60 border-b p-4 pb-3 sm:p-5">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <h3 class="text-base font-semibold">Active Screening Queue &amp; Target List Matches</h3>
                  <p class="text-muted-foreground mt-0.5 text-xs">
                    Real-time fuzzy scoring across OFAC SDN, Sectoral SSI, EU Restrictive Measures, and UN Security Council
                    lists.
                  </p>
                </div>
                <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                  <span class="bg-warning inline-block size-2 rounded-full"></span>
                  <span>Amber: Requires Review</span>
                  <span class="bg-destructive ml-2 inline-block size-2 rounded-full"></span>
                  <span>Red: Prohibited Asset Freeze</span>
                </div>
              </div>
            </div>
    
            <div class="overflow-x-auto">
              <table ui-table>
                <thead ui-table-header class="bg-muted/40">
                  <tr ui-table-row>
                    <th ui-table-head class="min-w-[220px]">Entity Name &amp; Classification</th>
                    <th ui-table-head class="min-w-[140px]">Match Score &amp; Status</th>
                    <th ui-table-head class="min-w-[200px]">Designated Watchlist / Program</th>
                    <th ui-table-head class="min-w-[220px]">Matched Attributes</th>
                    <th ui-table-head class="min-w-[130px]">Jurisdiction</th>
                    <th ui-table-head class="min-w-[220px] text-right">Actions</th>
                  </tr>
                </thead>
                <tbody ui-table-body>
                  @for (record of filteredRecords(); track record.id) {
                    <tr ui-table-row [class]="rowClass(record.matchStatus)">
                      <!-- 1. Entity Name & Classification -->
                      <td ui-table-cell class="py-3.5 align-top font-medium">
                        <div class="flex items-start gap-2.5">
                          <div [class]="avatarClass(record.entityType)">
                            @if (record.entityType === 'individual') {
                              <lucide-icon [img]="User" class="size-4" />
                            } @else {
                              <lucide-icon [img]="Building2" class="size-4" />
                            }
                          </div>
                          <div>
                            <div class="flex items-center gap-1.5">
                              <span class="text-foreground font-semibold">{{ record.entityName }}</span>
                              <span ui-badge variant="outline" class="px-1.5 py-0 text-xs uppercase">
                                {{ record.entityType }}
                              </span>
                            </div>
                            @if (record.originalScript) {
                              <p class="text-muted-foreground mt-0.5 text-xs">
                                {{ record.originalScript }}
                              </p>
                            }
                            @if (record.aliases && record.aliases.length > 0) {
                              <div class="text-muted-foreground/80 mt-1 flex flex-wrap gap-1 text-xs">
                                <span class="font-medium">AKAs:</span>
                                <span>{{ aliasPreview(record) }}</span>
                              </div>
                            }
                          </div>
                        </div>
                      </td>
    
                      <!-- 2. Match Score & Status -->
                      <td ui-table-cell class="py-3.5 align-top">
                        <div class="space-y-1">
                          @if (record.matchStatus === 'blocked') {
                            <div>
                              <span ui-badge variant="destructive" class="gap-1 text-xs font-semibold">
                                <lucide-icon [img]="Ban" class="size-3" />
                                <span>Blocked &amp; Frozen</span>
                              </span>
                            </div>
                          } @else if (record.matchStatus === 'cleared_false_positive') {
                            <div>
                              <span
                                ui-badge
                                variant="outline"
                                class="border-success/30 bg-success/10 text-success gap-1 text-xs font-semibold"
                              >
                                <lucide-icon [img]="CheckCircle2" class="size-3" />
                                <span>Cleared (False Positive)</span>
                              </span>
                            </div>
                          } @else if (record.matchScore >= 95) {
                            <div>
                              <span ui-badge variant="destructive" class="gap-1 text-xs font-semibold tabular-nums">
                                <lucide-icon [img]="ShieldX" class="size-3" />
                                <span>{{ record.matchScore }}% Critical Match</span>
                              </span>
                            </div>
                          } @else if (record.matchScore >= currentThreshold()) {
                            <div>
                              <span
                                ui-badge
                                variant="outline"
                                class="border-warning/40 bg-warning/15 text-warning gap-1 text-xs font-semibold tabular-nums"
                              >
                                <lucide-icon [img]="AlertTriangle" class="size-3" />
                                <span>{{ record.matchScore }}% Match</span>
                              </span>
                            </div>
                          } @else {
                            <div>
                              <span
                                ui-badge
                                variant="outline"
                                class="border-success/30 bg-success/10 text-success gap-1 text-xs font-semibold tabular-nums"
                              >
                                <lucide-icon [img]="Check" class="size-3" />
                                <span>{{ record.matchScore }}% Match · Clear</span>
                              </span>
                            </div>
                          }
    
                          <div class="text-muted-foreground text-xs tabular-nums">Screened {{ record.lastScreened }}</div>
                        </div>
                      </td>
    
                      <!-- 3. Target Sanctions List & Programs -->
                      <td ui-table-cell class="py-3.5 align-top">
                        <div class="space-y-1">
                          <div class="text-foreground text-xs font-medium">
                            {{ record.sanctionList }}
                          </div>
                          <div class="text-muted-foreground line-clamp-2 text-xs">
                            {{ record.program }}
                          </div>
                          @if (record.pepLevel) {
                            <div class="text-warning text-xs font-medium">
                              {{ record.pepLevel }}
                            </div>
                          }
                        </div>
                      </td>
    
                      <!-- 4. Matched Attributes -->
                      <td ui-table-cell class="py-3.5 align-top">
                        <div class="space-y-1 text-xs">
                          <div class="text-foreground font-medium">
                            {{ record.matchedAttributesSummary }}
                          </div>
                          <div class="text-muted-foreground tabular-nums">ID: {{ record.nationalIdOrLei }}</div>
                          <div class="text-muted-foreground max-w-[240px] truncate">
                            {{ record.address }}
                          </div>
                        </div>
                      </td>
    
                      <!-- 5. Jurisdiction -->
                      <td ui-table-cell class="py-3.5 align-top">
                        <div class="space-y-1 text-xs">
                          <div class="text-foreground flex items-center gap-1 font-medium">
                            <lucide-icon [img]="Globe" class="text-muted-foreground size-3 shrink-0" />
                            <span>{{ record.country }}</span>
                          </div>
                          <div class="text-muted-foreground">
                            {{ record.jurisdiction }}
                          </div>
                        </div>
                      </td>
    
                      <!-- 6. Actions -->
                      <td ui-table-cell class="py-3.5 text-right align-top">
                        <div class="flex flex-col items-end justify-end gap-1.5 sm:flex-row sm:items-center">
                          <button
                            ui-button
                            variant="outline"
                            size="sm"
                            class="h-8 cursor-pointer gap-1.5 text-xs"
                            (click)="openInvestigation(record)"
                          >
                            <lucide-icon [img]="Eye" class="size-3.5" />
                            <span>Review Match</span>
                          </button>
    
                          @if (
                            record.id === 'SANCT-2026-001' &&
                            record.matchStatus !== 'cleared_false_positive' &&
                            record.matchStatus !== 'blocked'
                          ) {
                            <button
                              ui-button
                              variant="ghost"
                              size="sm"
                              class="text-success hover:bg-success/10 hover:text-success h-8 cursor-pointer gap-1 text-xs"
                              (click)="handleClearFalsePositive(record)"
                            >
                              <lucide-icon [img]="Check" class="size-3.5" />
                              <span>Clear False Positive</span>
                            </button>
                            <button
                              ui-button
                              variant="destructive"
                              size="sm"
                              class="h-8 cursor-pointer gap-1 text-xs"
                              (click)="handleBlockEntity(record)"
                            >
                              <lucide-icon [img]="Ban" class="size-3.5" />
                              <span>Block &amp; Freeze</span>
                            </button>
                          }
    
                          @if (record.id === 'SANCT-2026-004' && record.matchStatus !== 'blocked') {
                            <button
                              ui-button
                              variant="destructive"
                              size="sm"
                              class="h-8 cursor-pointer gap-1 text-xs"
                              (click)="handleBlockEntity(record)"
                            >
                              <lucide-icon [img]="Ban" class="size-3.5" />
                              <span>Block Entity</span>
                            </button>
                          }
                        </div>
                      </td>
                    </tr>
                  }
    
                  @if (filteredRecords().length === 0) {
                    <tr ui-table-row>
                      <td ui-table-cell colspan="6" class="text-muted-foreground py-10 text-center">
                        <div class="flex flex-col items-center justify-center gap-2">
                          <lucide-icon [img]="FileSearch" class="text-muted-foreground/50 size-8" />
                          <p class="text-sm font-medium">No matching screening records found</p>
                          <p class="text-xs">Adjust your search keyword, country filter, or match threshold.</p>
                          <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="handleResetFilters()">
                            Reset Filters
                          </button>
                        </div>
                      </td>
                    </tr>
                  }
                </tbody>
              </table>
            </div>
          </div>
    
          <!-- Sanction Match Investigation Modal / Dialog -->
          <ui-sanctions-investigation-dialog
            [open]="isInvestigationOpen()"
            (openChange)="isInvestigationOpen.set($event)"
            [selectedRecord]="selectedRecord()"
            [thresholdValue]="currentThreshold()"
            (clear)="handleClearFalsePositive($event)"
            (escalate)="handleEscalate($event)"
            (block)="handleBlockEntity($event)"
          />
        </div>
      `,
    })
    export class UiComplianceSanctionsScreenerComponent {
      protected readonly AlertTriangle = AlertTriangle
      protected readonly Ban = Ban
      protected readonly Building2 = Building2
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Eye = Eye
      protected readonly FileSearch = FileSearch
      protected readonly Globe = Globe
      protected readonly Info = Info
      protected readonly RefreshCw = RefreshCw
      protected readonly RotateCcw = RotateCcw
      protected readonly Search = Search
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly ShieldX = ShieldX
      protected readonly Sliders = Sliders
      protected readonly User = User
      protected readonly Users = Users
      protected readonly X = X
    
      @Input() set initialSearch(v: string | undefined) {
        if (v !== undefined) this.searchQuery.set(v)
      }
      @Input() set initialCountry(v: string | undefined) {
        if (v !== undefined) this.selectedCountry.set(v)
      }
      @Input() set initialThreshold(v: number | undefined) {
        if (v !== undefined) this.thresholdValue.set([v])
      }
      @Input() set initialRecords(v: SanctionRecord[] | undefined) {
        if (v) this.records.set(cloneRecords(v))
      }
      @Input('class') className?: string
    
      readonly searchQuery = signal('Viktor Ivanov')
      readonly selectedCountry = signal('all')
      readonly selectedEntityType = signal<'all' | EntityType>('all')
      readonly thresholdValue = signal<number[]>([85])
      readonly records = signal<SanctionRecord[]>(cloneRecords(defaultSanctionRecords))
    
      readonly isBatchScreening = signal(false)
      readonly notificationBanner = signal<{
        message: string
        type: 'success' | 'warning' | 'info' | 'destructive'
      } | null>(null)
      readonly selectedRecord = signal<SanctionRecord | null>(null)
      readonly isInvestigationOpen = signal(false)
      private bannerTimer: ReturnType<typeof setTimeout> | undefined
      private batchTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly totalScreenedCount = 1420
    
      readonly currentThreshold = computed(() => this.thresholdValue()[0] ?? 85)
    
      readonly filteredRecords = computed(() => {
        const q = this.searchQuery().toLowerCase().trim()
        const country = this.selectedCountry()
        const entityType = this.selectedEntityType()
        return this.records().filter((record) => {
          if (q) {
            const matchesName = record.entityName.toLowerCase().includes(q)
            const matchesAlias = record.aliases.some((a) => a.toLowerCase().includes(q))
            const matchesList = record.sanctionList.toLowerCase().includes(q)
            const matchesId = record.nationalIdOrLei.toLowerCase().includes(q)
            const matchesSummary = record.matchedAttributesSummary.toLowerCase().includes(q)
            if (!matchesName && !matchesAlias && !matchesList && !matchesId && !matchesSummary) return false
          }
          if (country !== 'all' && record.country !== country) return false
          if (entityType !== 'all' && record.entityType !== entityType) return false
          return true
        })
      })
    
      readonly clearedCount = computed(() => {
        const baseCleared = 1408
        const additionalCleared = this.records().filter((r) => r.matchStatus === 'cleared_false_positive').length
        const newlyBlocked = this.records().filter((r) => r.matchStatus === 'blocked').length
        return baseCleared + additionalCleared - newlyBlocked
      })
    
      readonly flaggedCount = computed(() => {
        const basePending = 12
        const cleared = this.records().filter((r) => r.matchStatus === 'cleared_false_positive').length
        const blocked = this.records().filter((r) => r.matchStatus === 'blocked').length
        return Math.max(0, basePending - cleared - blocked)
      })
    
      readonly blockedCount = computed(() => this.records().filter((r) => r.matchStatus === 'blocked').length)
    
      get rootClass(): string {
        return cn('text-foreground w-full space-y-6', this.className)
      }
    
      get batchIconClass(): string {
        return cn('size-4', this.isBatchScreening() && 'animate-spin')
      }
    
      bannerClass(type: string): string {
        return cn(
          'flex items-center justify-between gap-3 rounded-lg border px-4 py-3 text-xs transition-colors duration-200 sm:text-sm',
          type === 'success' && 'border-success/30 bg-success/10 text-success',
          type === 'warning' && 'border-warning/30 bg-warning/10 text-warning',
          type === 'destructive' && 'bg-destructive/10 border-destructive/30 text-destructive',
          type === 'info' && 'bg-primary/10 border-primary/20 text-primary',
        )
      }
    
      entityPillClass(value: 'all' | EntityType): string {
        return cn(
          'flex-1 rounded px-2 py-1 text-xs font-medium transition-colors',
          this.selectedEntityType() === value
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      rowClass(status: string): string {
        return cn(
          'hover:bg-muted/30 transition-colors',
          status === 'potential_match' && 'bg-warning/[0.02]',
          status === 'prohibited_match' && 'bg-destructive/[0.03]',
          status === 'blocked' && 'bg-destructive/[0.06] opacity-90',
        )
      }
    
      avatarClass(entityType: EntityType): string {
        return cn(
          'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold',
          entityType === 'individual' ? 'bg-primary/10 text-primary' : 'bg-secondary text-secondary-foreground',
        )
      }
    
      aliasPreview(record: SanctionRecord): string {
        const shown = record.aliases.slice(0, 2).join(', ')
        return record.aliases.length > 2 ? `${shown} (+${record.aliases.length - 2} more)` : shown
      }
    
      onThresholdChange(value: number[] | number): void {
        this.thresholdValue.set(Array.isArray(value) ? value : [value])
      }
    
      openInvestigation(record: SanctionRecord): void {
        this.selectedRecord.set(record)
        this.isInvestigationOpen.set(true)
      }
    
      showNotification(message: string, type: 'success' | 'warning' | 'info' | 'destructive' = 'info'): void {
        this.notificationBanner.set({ message, type })
        clearTimeout(this.bannerTimer)
        this.bannerTimer = setTimeout(() => {
          if (this.notificationBanner()?.message === message) this.notificationBanner.set(null)
        }, 4500)
      }
    
      handleRunBatchScreening(): void {
        this.isBatchScreening.set(true)
        clearTimeout(this.batchTimer)
        this.batchTimer = setTimeout(() => {
          this.isBatchScreening.set(false)
          this.showNotification(
            'Batch screening completed. 1,420 entities cross-referenced against 4 global registries. 12 matches flagged.',
            'success',
          )
        }, 1200)
      }
    
      private patchRecord(record: SanctionRecord, patch: Partial<SanctionRecord>): void {
        this.records.update((prev) => prev.map((r) => (r.id === record.id ? { ...r, ...patch } : r)))
        const selected = this.selectedRecord()
        if (selected && selected.id === record.id) this.selectedRecord.set({ ...selected, ...patch })
      }
    
      handleClearFalsePositive(record: SanctionRecord): void {
        this.patchRecord(record, { matchStatus: 'cleared_false_positive' })
        this.showNotification(
          `False positive cleared for "${record.entityName}". Compliance audit log logged with officer signature.`,
          'success',
        )
      }
    
      handleBlockEntity(record: SanctionRecord): void {
        this.patchRecord(record, { matchStatus: 'blocked' })
        this.showNotification(
          `Entity "${record.entityName}" confirmed PROHIBITED. Asset freeze locked and SAR report staged for FinCEN / EU MLRO.`,
          'destructive',
        )
      }
    
      handleEscalate(record: SanctionRecord): void {
        this.showNotification(
          `Case for "${record.entityName}" escalated to Senior Compliance Officer & Legal Counsel for review.`,
          'warning',
        )
        this.isInvestigationOpen.set(false)
      }
    
      handleResetFilters(): void {
        this.searchQuery.set('')
        this.selectedCountry.set('all')
        this.selectedEntityType.set('all')
        this.thresholdValue.set([85])
        this.showNotification('Filters reset to default workbench view.', 'info')
      }
    }
    
  • src/app/components/blocks/compliance-sanctions-screener/sanctions-investigation-dialog.component.ts12.4 kB
  • src/app/components/blocks/compliance-sanctions-screener/compliance-sanctions-types.ts1 kB
  • src/app/components/blocks/compliance-sanctions-screener/compliance-sanctions-data.ts10.5 kB
  • src/app/components/blocks/compliance-sanctions-screener/index.ts0.4 kB

Raw manifest:https://uipkge.dev/r/angular/compliance-sanctions-screener.json