
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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 ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/compliance-sanctions-screener.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/compliance-sanctions-screener.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/compliance-sanctions-screener.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/compliance-sanctions-screener.jsonnpx shadcn-vue@latest add @uipkge/compliance-sanctions-screenerInstalls to:app/components/blocks/Type aliases exported from this item's source. Use these to shape the data you pass in.
SanctionAttributeComparisoninterface SanctionAttributeComparison {
field: string
submittedValue: string
watchlistValue: string
matchType: 'exact' | 'fuzzy' | 'partial' | 'clean' | 'unmatched'
score: number
note?: string
}SanctionRecordinterface 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[]
}NotificationBannerinterface NotificationBanner {
message: string
type: 'success' | 'warning' | 'destructive' | 'info'
}<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 & 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 <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 & 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 & Classification</TableHead>
<TableHead class="min-w-[140px]">Match Score & 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 & 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 & 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>
<script setup lang="ts">
import { Ban, Check, Layers, ShieldAlert, ShieldCheck } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { SanctionRecord } from './compliance-sanctions-types'
interface Props {
open: boolean
selectedRecord: SanctionRecord | null
thresholdValue: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
clear: [record: SanctionRecord]
escalate: [record: SanctionRecord]
block: [record: SanctionRecord]
}>()
function handleClear() {
if (props.selectedRecord) {
emit('clear', props.selectedRecord)
emit('update:open', false)
}
}
function handleEscalate() {
if (props.selectedRecord) {
emit('escalate', props.selectedRecord)
}
}
function handleBlock() {
if (props.selectedRecord) {
emit('block', props.selectedRecord)
emit('update:open', false)
}
}
</script>
<template>
<Dialog :open="open" @update:open="(val) => emit('update:open', val)">
<DialogContent v-if="selectedRecord" class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<div class="flex min-w-0 items-center gap-2.5">
<div
:class="
cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
selectedRecord.matchScore >= 85 ? 'bg-warning/15 text-warning' : 'bg-success/15 text-success',
)
"
>
<ShieldAlert v-if="selectedRecord.matchScore >= 85" class="size-5" />
<ShieldCheck v-else class="size-5" />
</div>
<div>
<DialogTitle class="text-base font-bold sm:text-lg">
Sanction Match Investigation & Attribute Comparison
</DialogTitle>
<DialogDescription class="text-muted-foreground mt-0.5 text-xs">
Detailed biometric, legal entity registry, and designated watchlist reconciliation audit trail.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div class="space-y-4 py-2">
<!-- Entity Overview Banner -->
<div class="border-border bg-muted/40 grid grid-cols-1 gap-3 rounded-lg border p-3 text-xs sm:grid-cols-3">
<div>
<span class="text-muted-foreground block">Subject Entity Name</span>
<strong class="text-foreground text-sm font-semibold">{{ selectedRecord.entityName }}</strong>
<div v-if="selectedRecord.originalScript" class="text-muted-foreground mt-0.5 text-xs">
{{ selectedRecord.originalScript }}
</div>
</div>
<div>
<span class="text-muted-foreground block">Fuzzy Algorithm Score</span>
<div class="mt-0.5 flex items-center gap-1.5">
<span
class="text-sm font-bold tabular-nums"
:class="selectedRecord.matchScore >= 85 ? 'text-warning' : 'text-success'"
>
{{ selectedRecord.matchScore }}% Match
</span>
<Badge
:variant="
selectedRecord.matchScore >= 95
? 'destructive'
: selectedRecord.matchScore >= 85
? 'outline'
: 'success'
"
class="py-0 text-xs whitespace-normal"
>
{{
selectedRecord.matchScore >= 95
? 'Critical Risk'
: selectedRecord.matchScore >= 85
? 'High Confidence'
: 'Clear / Clean'
}}
</Badge>
</div>
<span class="text-muted-foreground text-xs">Threshold: {{ thresholdValue }}%</span>
</div>
<div>
<span class="text-muted-foreground block">Primary Watchlist Source</span>
<strong class="text-foreground mt-0.5 line-clamp-1 font-medium">{{ selectedRecord.sanctionList }}</strong>
<span class="text-muted-foreground text-xs">{{ selectedRecord.jurisdiction }}</span>
</div>
</div>
<!-- Attribute Comparison Matrix -->
<div class="space-y-2">
<h4 class="text-muted-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase">
<Layers class="text-primary size-3.5" />
<span>Attribute-by-Attribute Verification Matrix</span>
</h4>
<div class="border-border overflow-hidden rounded-lg border">
<div class="overflow-x-auto">
<Table>
<TableHeader class="bg-muted/60">
<TableRow>
<TableHead class="w-[140px] text-xs">Attribute</TableHead>
<TableHead class="min-w-[160px] text-xs">Submitted Query Data</TableHead>
<TableHead class="min-w-[200px] text-xs">Designated Watchlist Record</TableHead>
<TableHead class="w-[130px] text-right text-xs">Match Verdict</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="(attr, idx) in selectedRecord.attributeComparisons"
:key="idx"
class="hover:bg-muted/20 text-xs"
>
<TableCell class="text-foreground py-2.5 align-top font-medium">
{{ attr.field }}
</TableCell>
<TableCell class="text-muted-foreground py-2.5 align-top">
<span class="text-foreground font-medium">{{ attr.submittedValue }}</span>
</TableCell>
<TableCell class="py-2.5 align-top">
<div class="space-y-0.5">
<span class="text-foreground font-medium">{{ attr.watchlistValue }}</span>
<p v-if="attr.note" class="text-muted-foreground/80 text-xs italic">
{{ attr.note }}
</p>
</div>
</TableCell>
<TableCell class="py-2.5 text-right align-top">
<Badge
v-if="attr.matchType === 'exact'"
variant="destructive"
class="text-xs font-semibold whitespace-normal tabular-nums"
>
{{ attr.score }}% Exact Match
</Badge>
<Badge
v-else-if="attr.matchType === 'fuzzy' || attr.matchType === 'partial'"
variant="outline"
class="border-warning/40 bg-warning/15 text-warning text-warning text-xs font-semibold whitespace-normal tabular-nums"
>
{{ attr.score }}% Fuzzy Hit
</Badge>
<Badge
v-else
wrap
variant="outline"
class="border-success/30 bg-success/10 text-success text-xs font-semibold"
>
Clear
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
<!-- Legal Authority & Compliance Advisory Warning -->
<div
v-if="selectedRecord.matchScore >= 85"
class="border-destructive/30 bg-destructive/5 text-foreground space-y-2 rounded-lg border p-3.5 text-xs"
>
<div class="text-destructive flex min-w-0 items-center gap-2 font-semibold">
<ShieldAlert class="size-4" />
<span>Mandatory Sanctions Compliance & Asset Freeze Notice (31 CFR Part 587)</span>
</div>
<p class="text-muted-foreground">
If confirmed prohibited, all property and interests in property of this entity within U.S. or EU
jurisdiction must be immediately blocked and reported to OFAC / designated national sanctions authorities
within 10 business days.
</p>
<div class="text-muted-foreground flex flex-wrap items-center gap-3 font-mono text-xs">
<span>Program: {{ selectedRecord.program }}</span>
<span>•</span>
<span>SAR Filing: Required within 30d</span>
</div>
</div>
</div>
<DialogFooter
class="border-border flex flex-col-reverse gap-2 border-t pt-3 sm:flex-row sm:items-center sm:justify-between"
>
<DialogClose as-child>
<Button variant="outline" size="sm" class="text-xs"> Dismiss / Close </Button>
</DialogClose>
<div class="flex min-w-0 items-center gap-2">
<Button
v-if="selectedRecord.matchStatus !== 'cleared_false_positive'"
variant="outline"
size="sm"
class="text-success hover:bg-success/10 hover:text-success gap-1.5 text-xs"
@click="handleClear"
>
<Check class="size-3.5" />
<span>Clear False Positive</span>
</Button>
<Button variant="secondary" size="sm" class="gap-1.5 text-xs" @click="handleEscalate">
<ShieldAlert class="size-3.5" />
<span>Escalate to MLRO</span>
</Button>
<Button
v-if="selectedRecord.matchStatus !== 'blocked'"
variant="destructive"
size="sm"
class="gap-1.5 text-xs"
@click="handleBlock"
>
<Ban class="size-3.5" />
<span>Confirm Prohibited & Block</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
import type { HTMLAttributes } from 'vue'
export type MatchStatus = 'potential_match' | 'prohibited_match' | 'clear' | 'cleared_false_positive' | 'blocked'
export type EntityType = 'individual' | 'corporate'
export interface SanctionAttributeComparison {
field: string
submittedValue: string
watchlistValue: string
matchType: 'exact' | 'fuzzy' | 'partial' | 'clean' | 'unmatched'
score: number
note?: string
}
export 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[]
}
export interface NotificationBanner {
message: string
type: 'success' | 'warning' | 'destructive' | 'info'
}
export interface ComplianceSanctionsScreenerProps {
initialSearch?: string
initialCountry?: string
initialThreshold?: number
initialRecords?: SanctionRecord[]
class?: HTMLAttributes['class']
}
import type { SanctionRecord } from './compliance-sanctions-types'
export const defaultSanctionRecords: SanctionRecord[] = [
{
id: 'SANCT-2026-001',
entityName: 'Viktor Ivanov',
originalScript: 'Иванов Виктор Петрович',
entityType: 'individual',
matchScore: 94,
matchStatus: 'potential_match',
sanctionList: 'OFAC SDN List · Sectoral Sanctions',
program: 'UKRAINE-EO13662 · Executive Order 14024',
matchedAttributesSummary: 'DOB 1974, Russia, Passport 77-04-889102',
country: 'Russia',
jurisdiction: 'US (OFAC) / EU / UK',
dateOfBirthOrIncorporation: '1974-05-12',
nationalIdOrLei: '77-04-889102 (RUS)',
address: 'Presnenskaya Naberezhnaya 12, Moscow, Russia',
aliases: ['Victor Ivanov', 'V. P. Ivanov', 'Viktor Petrovich Ivanov'],
pepLevel: 'PEP Tier 2 · Former Deputy Minister of Industry',
remarks: 'Designated under Executive Order 14024 for operating in the financial and defense sector.',
lastScreened: '10m ago',
attributeComparisons: [
{
field: 'Full Legal Name',
submittedValue: 'Viktor Ivanov',
watchlistValue: 'IVANOV, Viktor Petrovich (Иванов Виктор Петрович)',
matchType: 'fuzzy',
score: 96,
note: 'Transliteration and patronymic expansion matched',
},
{
field: 'Known Aliases & AKAs',
submittedValue: 'Victor Ivanov, V. P. Ivanov',
watchlistValue: 'Viktor Ivanov, Victor Petrovich Ivanov, Victor Ivanov-Karpov',
matchType: 'partial',
score: 94,
note: 'Direct alias hit in OFAC Specially Designated database',
},
{
field: 'Date of Birth / Inception',
submittedValue: '1974-05-12',
watchlistValue: '1974-05-12 (Moscow, USSR / Russian Federation)',
matchType: 'exact',
score: 100,
note: '100% exact cryptographic DOB date match',
},
{
field: 'Passport / National ID',
submittedValue: '77-04-889102 (Russian Federation)',
watchlistValue: '77-04-889102 / Int. Passport 51-09-112344',
matchType: 'exact',
score: 100,
note: 'Official primary national identity document match',
},
{
field: 'Registered Address',
submittedValue: 'Presnenskaya Naberezhnaya 12, Moscow',
watchlistValue: 'Presnenskaya Embankment 12, Fl. 4, Moscow 123112, Russia',
matchType: 'fuzzy',
score: 92,
note: 'Street and district normalization matched',
},
{
field: 'PEP Status & Legal Program',
submittedValue: 'Executive Director (Disclosed Non-PEP)',
watchlistValue: 'OFAC SDN [UKRAINE-EO13662] · PEP Tier 2 · Mandatory Asset Freeze',
matchType: 'exact',
score: 100,
note: 'Undeclared PEP Tier 2 designation active on UK HMT / EU Sanctions',
},
],
},
{
id: 'SANCT-2026-002',
entityName: 'Sarah Connor',
originalScript: 'Sarah Connor',
entityType: 'individual',
matchScore: 12,
matchStatus: 'clear',
sanctionList: 'No Sanctions Matches (All Databases Checked)',
program: 'None · Standard Retail Profile',
matchedAttributesSummary: 'No sanction matches · Low risk profile',
country: 'United States',
jurisdiction: 'Global / FATF Compliant',
dateOfBirthOrIncorporation: '1985-02-28',
nationalIdOrLei: 'SSN ***-**-4910',
address: '742 Evergreen Terrace, Los Angeles, CA 90001, USA',
aliases: ['Sarah J. Connor'],
pepLevel: 'Non-PEP',
remarks: 'Clear of all OFAC, EU, UN, and PEP lists. Auto-approved by automated compliance pipeline.',
lastScreened: '18m ago',
attributeComparisons: [
{
field: 'Full Legal Name',
submittedValue: 'Sarah Connor',
watchlistValue: 'CONNOR, Sara Helena (Low Soundex Similarity)',
matchType: 'fuzzy',
score: 18,
note: 'Distant phonetical overlap below decision threshold',
},
{
field: 'Known Aliases & AKAs',
submittedValue: 'Sarah J. Connor',
watchlistValue: 'No matching designated aliases found',
matchType: 'clean',
score: 0,
note: 'Zero hits across all official alias directories',
},
{
field: 'Date of Birth / Inception',
submittedValue: '1985-02-28',
watchlistValue: '1962-11-04 (Bogota, Colombia)',
matchType: 'unmatched',
score: 0,
note: 'DOB discrepancy (>22 years divergence)',
},
{
field: 'Passport / National ID',
submittedValue: 'SSN ***-**-4910 (USA)',
watchlistValue: 'COL-9902148 (Republic of Colombia)',
matchType: 'unmatched',
score: 0,
note: 'Different country of issuance and identifier structure',
},
{
field: 'Registered Address',
submittedValue: '742 Evergreen Terrace, Los Angeles, CA, USA',
watchlistValue: 'Calle 72 #10-34, Bogota, Colombia',
matchType: 'unmatched',
score: 0,
note: 'Zero geographic intersection',
},
{
field: 'PEP Status & Legal Program',
submittedValue: 'Individual Retail Client',
watchlistValue: 'Clean / Unrestricted Jurisdiction',
matchType: 'clean',
score: 0,
note: 'No adverse media or PEP exposure',
},
],
},
{
id: 'SANCT-2026-003',
entityName: 'Elena Rostova',
originalScript: 'Elena Rostova-Dimitriou',
entityType: 'individual',
matchScore: 0,
matchStatus: 'clear',
sanctionList: 'No Sanctions Matches (All Databases Checked)',
program: 'None · Private Banking Profile',
matchedAttributesSummary: 'No sanction matches · High assurance clean record',
country: 'Cyprus',
jurisdiction: 'EU / Global Compliant',
dateOfBirthOrIncorporation: '1991-09-17',
nationalIdOrLei: 'CY-ID 9920194',
address: 'Archbishop Makarios III Ave 45, Limassol 3025, Cyprus',
aliases: ['Elena Rostova-Dimitriou'],
pepLevel: 'Non-PEP',
remarks: 'Verified clean record across UN Consolidated, OFAC SDN, UK HMT, and Interpol Red Notices.',
lastScreened: '32m ago',
attributeComparisons: [
{
field: 'Full Legal Name',
submittedValue: 'Elena Rostova',
watchlistValue: 'No target record matches identified in any watchlist',
matchType: 'clean',
score: 0,
note: 'Zero hits across 42 global sanction jurisdictions',
},
{
field: 'Known Aliases & AKAs',
submittedValue: 'Elena Rostova-Dimitriou',
watchlistValue: 'No target record matches',
matchType: 'clean',
score: 0,
note: 'Zero alias records found',
},
{
field: 'Date of Birth / Inception',
submittedValue: '1991-09-17',
watchlistValue: 'No target record matches',
matchType: 'clean',
score: 0,
note: 'Clean verification result',
},
{
field: 'Passport / National ID',
submittedValue: 'CY-ID 9920194 (Cyprus / EU)',
watchlistValue: 'No target record matches',
matchType: 'clean',
score: 0,
note: 'Clean verification result',
},
{
field: 'Registered Address',
submittedValue: 'Archbishop Makarios III Ave 45, Limassol, Cyprus',
watchlistValue: 'No target record matches',
matchType: 'clean',
score: 0,
note: 'Verified standard EU residential registry',
},
{
field: 'PEP Status & Legal Program',
submittedValue: 'Private Banking Client',
watchlistValue: 'Verified Non-PEP · Zero Adverse Media Flag',
matchType: 'clean',
score: 0,
note: 'Clean compliance status',
},
],
},
{
id: 'SANCT-2026-004',
entityName: 'Sberbank Trade Corp',
originalScript: 'ПАО Сбербанк / Sberbank Trade Corp',
entityType: 'corporate',
matchScore: 98,
matchStatus: 'prohibited_match',
sanctionList: 'EU Sanctions Article 5',
program: 'EU Reg 833/2014 Annex XIII · UK Sanctions S.I. 2022/194 · OFAC SSI Directives',
matchedAttributesSummary: 'LEI RU-9912048, Sector: Financials, Subsidiary of Sberbank PJSC',
country: 'Russia',
jurisdiction: 'EU / UK / OFAC Sectoral',
dateOfBirthOrIncorporation: '2008-11-14',
nationalIdOrLei: 'LEI: 253400X89892182 / INN: 7707083893',
address: 'Vavilova St 19, Moscow, 117997, Russia',
aliases: ['Sber Trade LLC', 'Sberbank Trading House', 'PJSC Sberbank Sub-Entity'],
pepLevel: 'State-Owned Enterprise (SOE) · Comprehensive Asset Freeze',
remarks:
'Subject to broad asset freeze and prohibitions on financial dealings, capital markets, and correspondent accounts.',
lastScreened: '1h ago',
attributeComparisons: [
{
field: 'Legal Entity Name',
submittedValue: 'Sberbank Trade Corp',
watchlistValue: 'PJSC SBERBANK / Sberbank Trade Corp (ПАО Сбербанк)',
matchType: 'exact',
score: 99,
note: 'Substantial majority-owned direct subsidiary matching designated parent',
},
{
field: 'Known Aliases & AKAs',
submittedValue: 'Sber Trade LLC, Sberbank Trading House',
watchlistValue: 'Sberbank Trading House / Sber Trade / Sberbank Capital LLC',
matchType: 'exact',
score: 100,
note: '100% match on registered trading names in EU Annex XIII',
},
{
field: 'Date of Incorporation',
submittedValue: '2008-11-14',
watchlistValue: '2008-11-14 (Moscow, Russian Federation)',
matchType: 'exact',
score: 100,
note: 'Registration date matching corporate charter record',
},
{
field: 'LEI / Corporate Tax ID',
submittedValue: 'LEI 253400X89892182 / INN 7707083893',
watchlistValue: 'LEI 253400X89892182 / INN 7707083893 / OGRN 1027700132195',
matchType: 'exact',
score: 100,
note: 'Unique global Legal Entity Identifier (LEI) direct match',
},
{
field: 'Registered Head Office',
submittedValue: 'Vavilova St 19, Moscow, 117997, Russia',
watchlistValue: '19 Vavilova St, Moscow 117997, Russian Federation',
matchType: 'exact',
score: 98,
note: 'Exact match with sanctioned financial headquarters address',
},
{
field: 'Sanction Program & Enforcement',
submittedValue: 'Corporate Investment Account',
watchlistValue: 'EU Article 5 / UK HMT Sanctions / OFAC 50% Rule Asset Freeze',
matchType: 'exact',
score: 100,
note: 'Mandatory transaction blocking and immediate regulatory freeze required',
},
],
},
]
Raw manifest:https://uipkge.dev/r/vue/compliance-sanctions-screener.json