UIPackage
Menu

Framework

Change language

Boilerplate 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.

Also available for React ->

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/compliance-sanctions-screener.json
Named registry:npx shadcn-vue@latest add @uipkge/compliance-sanctions-screenerInstalls to:app/components/blocks/

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 (4)

  • app/components/blocks/ComplianceSanctionsScreener.vue31 kB
    <script setup lang="ts">
    import { computed, ref, watch } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import {
      AlertTriangle,
      Ban,
      Building2,
      Check,
      CheckCircle2,
      Eye,
      FileSearch,
      Globe,
      Info,
      RefreshCw,
      RotateCcw,
      Search,
      ShieldAlert,
      ShieldCheck,
      ShieldX,
      Sliders,
      User,
      Users,
      X,
    } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
    import { Input } from '@/components/ui/input'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { Slider } from '@/components/ui/slider'
    import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
    import type {
      ComplianceSanctionsScreenerProps,
      EntityType,
      MatchStatus,
      NotificationBanner,
      SanctionAttributeComparison,
      SanctionRecord,
    } from './compliance-sanctions-types'
    import { defaultSanctionRecords } from './compliance-sanctions-data'
    import SanctionsInvestigationDialog from './SanctionsInvestigationDialog.vue'
    
    export type {
      ComplianceSanctionsScreenerProps,
      EntityType,
      MatchStatus,
      NotificationBanner,
      SanctionAttributeComparison,
      SanctionRecord,
    }
    
    const props = withDefaults(defineProps<ComplianceSanctionsScreenerProps>(), {
      initialSearch: 'Viktor Ivanov',
      initialCountry: 'all',
      initialThreshold: 85,
    })
    
    const searchQuery = ref(props.initialSearch)
    const selectedCountry = ref(props.initialCountry)
    const selectedEntityType = ref<'all' | EntityType>('all')
    const thresholdValue = ref<number[]>([props.initialThreshold])
    
    const records = ref<SanctionRecord[]>(
      props.initialRecords
        ? JSON.parse(JSON.stringify(props.initialRecords))
        : JSON.parse(JSON.stringify(defaultSanctionRecords)),
    )
    
    // Watch props in case parent changes them
    watch(
      () => props.initialRecords,
      (newVal) => {
        if (newVal) {
          records.value = JSON.parse(JSON.stringify(newVal))
        }
      },
    )
    
    const isBatchScreening = ref(false)
    const notificationBanner = ref<{ message: string; type: 'success' | 'warning' | 'info' | 'destructive' } | null>(null)
    
    const selectedRecord = ref<SanctionRecord | null>(null)
    const isInvestigationOpen = ref(false)
    
    function openInvestigation(record: SanctionRecord) {
      selectedRecord.value = record
      isInvestigationOpen.value = true
    }
    
    function showNotification(message: string, type: 'success' | 'warning' | 'info' | 'destructive' = 'info') {
      notificationBanner.value = { message, type }
      setTimeout(() => {
        if (notificationBanner.value?.message === message) {
          notificationBanner.value = null
        }
      }, 4500)
    }
    
    function handleRunBatchScreening() {
      isBatchScreening.value = true
      setTimeout(() => {
        isBatchScreening.value = false
        showNotification(
          'Batch screening completed. 1,420 entities cross-referenced against 4 global registries. 12 matches flagged.',
          'success',
        )
      }, 1200)
    }
    
    function handleClearFalsePositive(record: SanctionRecord) {
      record.matchStatus = 'cleared_false_positive'
      showNotification(
        `False positive cleared for "${record.entityName}". Compliance audit log logged with officer signature.`,
        'success',
      )
    }
    
    function handleBlockEntity(record: SanctionRecord) {
      record.matchStatus = 'blocked'
      showNotification(
        `Entity "${record.entityName}" confirmed PROHIBITED. Asset freeze locked and SAR report staged for FinCEN / EU MLRO.`,
        'destructive',
      )
    }
    
    function handleEscalate(record: SanctionRecord) {
      showNotification(
        `Case for "${record.entityName}" escalated to Senior Compliance Officer & Legal Counsel for review.`,
        'warning',
      )
      isInvestigationOpen.value = false
    }
    
    function handleResetFilters() {
      searchQuery.value = ''
      selectedCountry.value = 'all'
      selectedEntityType.value = 'all'
      thresholdValue.value = [85]
      showNotification('Filters reset to default workbench view.', 'info')
    }
    
    const currentThreshold = computed(() => thresholdValue.value[0] ?? 85)
    
    const filteredRecords = computed(() => {
      return records.value.filter((record) => {
        // Search query filter
        if (searchQuery.value.trim()) {
          const q = searchQuery.value.toLowerCase().trim()
          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
          }
        }
    
        // Country filter
        if (selectedCountry.value !== 'all' && record.country !== selectedCountry.value) {
          return false
        }
    
        // Entity Type filter
        if (selectedEntityType.value !== 'all' && record.entityType !== selectedEntityType.value) {
          return false
        }
    
        return true
      })
    })
    
    // Telemetry statistics
    const totalScreenedCount = 1420
    const clearedCount = computed(() => {
      const baseCleared = 1408
      const additionalCleared = records.value.filter((r) => r.matchStatus === 'cleared_false_positive').length
      const newlyBlocked = records.value.filter((r) => r.matchStatus === 'blocked').length
      return baseCleared + additionalCleared - newlyBlocked
    })
    
    const flaggedCount = computed(() => {
      const basePending = 12
      const cleared = records.value.filter((r) => r.matchStatus === 'cleared_false_positive').length
      const blocked = records.value.filter((r) => r.matchStatus === 'blocked').length
      return Math.max(0, basePending - cleared - blocked)
    })
    
    const blockedCount = computed(() => {
      const manuallyBlocked = records.value.filter((r) => r.matchStatus === 'blocked').length
      return manuallyBlocked
    })
    </script>
    
    <template>
      <div :class="cn('text-foreground w-full space-y-6', props.class)">
        <!-- 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>
              <Badge wrap 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 class="font-medium">OFAC SDN, EU Consolidated, UK HMT, UN Sanctions · Synced 10m ago</span>
              </Badge>
            </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
              variant="default"
              size="sm"
              :disabled="isBatchScreening"
              class="cursor-pointer gap-2 shadow-xs"
              @click="handleRunBatchScreening"
            >
              <RefreshCw :class="cn('size-4', isBatchScreening && 'animate-spin')" />
              <span>{{ isBatchScreening ? 'Screening 1,420 Records...' : 'Run Batch Screening' }}</span>
            </Button>
          </div>
        </div>
    
        <!-- Notification Toast / Banner -->
        <div
          v-if="notificationBanner"
          :class="
            cn(
              'flex items-center justify-between gap-3 rounded-lg border px-4 py-3 text-xs transition-colors duration-200 sm:text-sm',
              notificationBanner.type === 'success' && 'border-success/30 bg-success/10 text-success',
              notificationBanner.type === 'warning' && 'border-warning/30 bg-warning/10 text-warning',
              notificationBanner.type === 'destructive' && 'bg-destructive/10 border-destructive/30 text-destructive',
              notificationBanner.type === 'info' && 'bg-primary/10 border-primary/20 text-primary',
            )
          "
        >
          <div class="flex min-w-0 items-center gap-2">
            <CheckCircle2 v-if="notificationBanner.type === 'success'" class="size-4 shrink-0" />
            <AlertTriangle v-else-if="notificationBanner.type === 'warning'" class="size-4 shrink-0" />
            <Ban v-else-if="notificationBanner.type === 'destructive'" class="size-4 shrink-0" />
            <Info v-else class="size-4 shrink-0" />
            <span>{{ notificationBanner.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 = null"
          >
            <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 -->
          <Card class="border-border/80 border shadow-xs">
            <CardContent 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">
                  <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>
            </CardContent>
          </Card>
    
          <!-- Card 2: Clear / No Matches -->
          <Card class="border-border/80 border shadow-xs">
            <CardContent 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">
                  <ShieldCheck class="size-4" />
                </div>
              </div>
              <div class="mt-3 flex items-baseline gap-2">
                <span class="text-success text-success text-2xl font-bold tracking-tight tabular-nums">
                  {{ clearedCount.toLocaleString() }}
                </span>
                <span class="text-success 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>
            </CardContent>
          </Card>
    
          <!-- Card 3: Potential Matches Flagged -->
          <Card class="border-border/80 border shadow-xs">
            <CardContent 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">
                  <AlertTriangle class="size-4" />
                </div>
              </div>
              <div class="mt-3 flex items-baseline gap-2">
                <span class="text-warning 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>
            </CardContent>
          </Card>
    
          <!-- Card 4: Confirmed Prohibited Matches -->
          <Card class="border-border/80 border shadow-xs">
            <CardContent 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">
                  <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>
            </CardContent>
          </Card>
        </div>
    
        <!-- Real-Time Entity Search Bar & Fuzzy Threshold Workbench -->
        <Card class="border-border/80 bg-card border shadow-xs">
          <CardContent 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">
                  <Search class="text-muted-foreground size-3.5" />
                  <span>Search Individual / Entity Name</span>
                </label>
                <div class="relative">
                  <Input
                    id="entity-search-input"
                    v-model="searchQuery"
                    type="text"
                    placeholder="Search name, alias, ID or passport..."
                    class="bg-background pl-8 text-xs sm:text-sm"
                  />
                  <Search
                    class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                  />
                  <button
                    aria-label="Clear search"
                    v-if="searchQuery"
                    type="button"
                    class="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 -translate-y-1/2"
                    @click="searchQuery = ''"
                  >
                    <X class="size-3.5" />
                  </button>
                </div>
              </div>
    
              <!-- Country Filter Dropdown -->
              <div class="space-y-1.5 md:col-span-3">
                <label class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                  <Globe class="text-muted-foreground size-3.5" />
                  <span>Country / Domicile Filter</span>
                </label>
                <Select v-model="selectedCountry">
                  <SelectTrigger class="bg-background w-full text-xs sm:text-sm [&>span]:truncate [&>svg]:shrink-0">
                    <SelectValue placeholder="All Countries" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="all">All Countries</SelectItem>
                    <SelectItem value="Russia">Russia (Russian Federation)</SelectItem>
                    <SelectItem value="United States">United States (USA)</SelectItem>
                    <SelectItem value="Cyprus">Cyprus (EU)</SelectItem>
                    <SelectItem value="United Kingdom">United Kingdom (UK)</SelectItem>
                    <SelectItem value="Switzerland">Switzerland (CH)</SelectItem>
                    <SelectItem value="United Arab Emirates">United Arab Emirates (UAE)</SelectItem>
                  </SelectContent>
                </Select>
              </div>
    
              <!-- Entity Type Pills -->
              <div class="space-y-1.5 md:col-span-2">
                <label class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                  <Building2 class="text-muted-foreground size-3.5" />
                  <span>Entity Type</span>
                </label>
                <div class="bg-muted border-input flex h-9 items-center gap-1 rounded-md border p-1">
                  <button
                    type="button"
                    :class="
                      cn(
                        'flex-1 rounded px-2 py-1 text-xs font-medium transition-colors',
                        selectedEntityType === 'all'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="selectedEntityType = 'all'"
                  >
                    All
                  </button>
                  <button
                    type="button"
                    :class="
                      cn(
                        'flex-1 rounded px-2 py-1 text-xs font-medium transition-colors',
                        selectedEntityType === 'individual'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="selectedEntityType = 'individual'"
                  >
                    Indiv.
                  </button>
                  <button
                    type="button"
                    :class="
                      cn(
                        'flex-1 rounded px-2 py-1 text-xs font-medium transition-colors',
                        selectedEntityType === 'corporate'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="selectedEntityType = '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">
                  <label class="text-foreground flex items-center gap-1.5 text-xs font-medium">
                    <Sliders class="text-muted-foreground size-3.5" />
                    <span>Match Threshold</span>
                  </label>
                  <Badge wrap variant="secondary" class="text-xs font-semibold tabular-nums">
                    {{ currentThreshold }}% Fuzzy Match
                  </Badge>
                </div>
                <div class="pt-1.5">
                  <Slider v-model="thresholdValue" :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
                >
                <span
                  v-if="searchQuery || selectedCountry !== 'all' || selectedEntityType !== 'all'"
                  class="text-muted-foreground/60"
                  >· Filters applied</span
                >
              </div>
              <div class="flex min-w-0 items-center gap-2">
                <Button
                  v-if="searchQuery || selectedCountry !== 'all' || selectedEntityType !== 'all' || currentThreshold !== 85"
                  variant="ghost"
                  size="sm"
                  class="text-muted-foreground hover:text-foreground h-7 gap-1 text-xs"
                  @click="handleResetFilters"
                >
                  <RotateCcw class="size-3" />
                  <span>Reset Filters</span>
                </Button>
              </div>
            </div>
          </CardContent>
        </Card>
    
        <!-- Screening Match Results Table -->
        <Card class="border-border/80 overflow-hidden border shadow-xs">
          <CardHeader 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>
                <CardTitle class="text-base font-semibold">Active Screening Queue &amp; Target List Matches</CardTitle>
                <CardDescription class="mt-0.5 text-xs">
                  Real-time fuzzy scoring across OFAC SDN, Sectoral SSI, EU Restrictive Measures, and UN Security Council
                  lists.
                </CardDescription>
              </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>Amber: Requires Review</span>
                <span class="bg-destructive ml-2 inline-block size-2 rounded-full" />
                <span>Red: Prohibited Asset Freeze</span>
              </div>
            </div>
          </CardHeader>
    
          <div class="overflow-x-auto">
            <Table>
              <TableHeader class="bg-muted/40">
                <TableRow>
                  <TableHead class="min-w-[220px]">Entity Name &amp; Classification</TableHead>
                  <TableHead class="min-w-[140px]">Match Score &amp; Status</TableHead>
                  <TableHead class="min-w-[200px]">Designated Watchlist / Program</TableHead>
                  <TableHead class="min-w-[220px]">Matched Attributes</TableHead>
                  <TableHead class="min-w-[130px]">Jurisdiction</TableHead>
                  <TableHead class="min-w-[220px] text-right">Actions</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                <TableRow
                  v-for="record in filteredRecords"
                  :key="record.id"
                  :class="
                    cn(
                      'hover:bg-muted/30 transition-colors',
                      record.matchStatus === 'potential_match' && 'bg-warning/[0.02]',
                      record.matchStatus === 'prohibited_match' && 'bg-destructive/[0.03]',
                      record.matchStatus === 'blocked' && 'bg-destructive/[0.06] opacity-90',
                    )
                  "
                >
                  <!-- 1. Entity Name & Classification -->
                  <TableCell class="py-3.5 align-top font-medium">
                    <div class="flex items-start gap-2.5">
                      <div
                        :class="
                          cn(
                            'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold',
                            record.entityType === 'individual'
                              ? 'bg-primary/10 text-primary'
                              : 'bg-secondary text-secondary-foreground',
                          )
                        "
                      >
                        <User v-if="record.entityType === 'individual'" class="size-4" />
                        <Building2 v-else class="size-4" />
                      </div>
                      <div>
                        <div class="flex items-center gap-1.5">
                          <span class="text-foreground font-semibold">{{ record.entityName }}</span>
                          <Badge wrap variant="outline" class="px-1.5 py-0 text-xs uppercase">
                            {{ record.entityType }}
                          </Badge>
                        </div>
                        <p v-if="record.originalScript" class="text-muted-foreground mt-0.5 text-xs">
                          {{ record.originalScript }}
                        </p>
                        <div
                          v-if="record.aliases && record.aliases.length > 0"
                          class="text-muted-foreground/80 mt-1 flex flex-wrap gap-1 text-xs"
                        >
                          <span class="font-medium">AKAs:</span>
                          <span
                            >{{ record.aliases.slice(0, 2).join(', ')
                            }}{{ record.aliases.length > 2 ? ` (+${record.aliases.length - 2} more)` : '' }}</span
                          >
                        </div>
                      </div>
                    </div>
                  </TableCell>
    
                  <!-- 2. Match Score & Status -->
                  <TableCell class="py-3.5 align-top">
                    <div class="space-y-1">
                      <!-- Badges based on score and status -->
                      <div v-if="record.matchStatus === 'blocked'">
                        <Badge wrap variant="destructive" class="gap-1 text-xs font-semibold">
                          <Ban class="size-3" />
                          <span>Blocked &amp; Frozen</span>
                        </Badge>
                      </div>
                      <div v-else-if="record.matchStatus === 'cleared_false_positive'">
                        <Badge
                          wrap
                          variant="outline"
                          class="border-success/30 bg-success/10 text-success gap-1 text-xs font-semibold"
                        >
                          <CheckCircle2 class="size-3" />
                          <span>Cleared (False Positive)</span>
                        </Badge>
                      </div>
                      <div v-else-if="record.matchScore >= 95">
                        <Badge wrap variant="destructive" class="gap-1 text-xs font-semibold tabular-nums">
                          <ShieldX class="size-3" />
                          <span>{{ record.matchScore }}% Critical Match</span>
                        </Badge>
                      </div>
                      <div v-else-if="record.matchScore >= currentThreshold">
                        <Badge
                          wrap
                          variant="outline"
                          class="border-warning/40 bg-warning/15 text-warning text-warning gap-1 text-xs font-semibold tabular-nums"
                        >
                          <AlertTriangle class="size-3" />
                          <span>{{ record.matchScore }}% Match</span>
                        </Badge>
                      </div>
                      <div v-else>
                        <Badge
                          wrap
                          variant="outline"
                          class="border-success/30 bg-success/10 text-success text-success gap-1 text-xs font-semibold tabular-nums"
                        >
                          <Check class="size-3" />
                          <span>{{ record.matchScore }}% Match · Clear</span>
                        </Badge>
                      </div>
    
                      <div class="text-muted-foreground text-xs tabular-nums">Screened {{ record.lastScreened }}</div>
                    </div>
                  </TableCell>
    
                  <!-- 3. Target Sanctions List & Programs -->
                  <TableCell 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>
                      <div v-if="record.pepLevel" class="text-warning text-xs font-medium">
                        {{ record.pepLevel }}
                      </div>
                    </div>
                  </TableCell>
    
                  <!-- 4. Matched Attributes -->
                  <TableCell 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>
                  </TableCell>
    
                  <!-- 5. Jurisdiction -->
                  <TableCell class="py-3.5 align-top">
                    <div class="space-y-1 text-xs">
                      <div class="text-foreground flex items-center gap-1 font-medium">
                        <Globe class="text-muted-foreground size-3 shrink-0" />
                        <span>{{ record.country }}</span>
                      </div>
                      <div class="text-muted-foreground">
                        {{ record.jurisdiction }}
                      </div>
                    </div>
                  </TableCell>
    
                  <!-- 6. Actions -->
                  <TableCell 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">
                      <!-- Review Match button always present -->
                      <Button
                        variant="outline"
                        size="sm"
                        class="h-8 cursor-pointer gap-1.5 text-xs"
                        @click="openInvestigation(record)"
                      >
                        <Eye class="size-3.5" />
                        <span>Review Match</span>
                      </Button>
    
                      <!-- Contextual buttons for Viktor Ivanov -->
                      <template
                        v-if="
                          record.id === 'SANCT-2026-001' &&
                          record.matchStatus !== 'cleared_false_positive' &&
                          record.matchStatus !== 'blocked'
                        "
                      >
                        <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)"
                        >
                          <Check class="size-3.5" />
                          <span>Clear False Positive</span>
                        </Button>
                        <Button
                          variant="destructive"
                          size="sm"
                          class="h-8 cursor-pointer gap-1 text-xs"
                          @click="handleBlockEntity(record)"
                        >
                          <Ban class="size-3.5" />
                          <span>Block &amp; Freeze</span>
                        </Button>
                      </template>
    
                      <!-- Contextual buttons for Sberbank -->
                      <template v-if="record.id === 'SANCT-2026-004' && record.matchStatus !== 'blocked'">
                        <Button
                          variant="destructive"
                          size="sm"
                          class="h-8 cursor-pointer gap-1 text-xs"
                          @click="handleBlockEntity(record)"
                        >
                          <Ban class="size-3.5" />
                          <span>Block Entity</span>
                        </Button>
                      </template>
                    </div>
                  </TableCell>
                </TableRow>
    
                <TableRow v-if="filteredRecords.length === 0">
                  <TableCell colspan="6" class="text-muted-foreground py-10 text-center">
                    <div class="flex flex-col items-center justify-center gap-2">
                      <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 variant="outline" size="sm" class="mt-2 text-xs" @click="handleResetFilters">
                        Reset Filters
                      </Button>
                    </div>
                  </TableCell>
                </TableRow>
              </TableBody>
            </Table>
          </div>
        </Card>
    
        <!-- Sanction Match Investigation Modal / Dialog -->
        <SanctionsInvestigationDialog
          v-model:open="isInvestigationOpen"
          :selected-record="selectedRecord"
          :threshold-value="currentThreshold"
          @clear="handleClearFalsePositive"
          @escalate="handleEscalate"
          @block="handleBlockEntity"
        />
      </div>
    </template>
    
  • app/components/blocks/SanctionsInvestigationDialog.vue10 kB
  • app/components/blocks/compliance-sanctions-types.ts1.2 kB
  • app/components/blocks/compliance-sanctions-data.ts10.5 kB

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