feat: add /dashboard/osint — registre naționale + screening sancțiuni

Pagina OSINT cu două tab-uri:
  🏢 Registre Naționale — company lookup RO/DE/UK/US via POST /v1/dataquery/company/lookup
     Rezultate tabelare per țară cu scroll orizontal, count per sursă.
  🚨 Screening Sancțiuni — POST /v1/dataquery/sanctions
     Verdict HIT/CLEAR, detalii OFAC SDN + OpenSanctions (Yente), scor potrivire.
This commit is contained in:
CEO-OS Deploy 2026-08-27 22:10:18 +02:00
parent e9ef81d272
commit a0c7fd57f9

View file

@ -0,0 +1,398 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
// ─── Types ────────────────────────────────────────────────────────────────────
interface CountryResult {
country: string;
label: string;
count: number;
columns: string[];
rows: unknown[][];
}
interface CompanyLookupResponse {
query: string;
results: CountryResult[];
errors: string[];
total_hits: number;
}
interface OfacHit {
source: string;
hits: number;
columns: string[];
rows: unknown[][];
error: string | null;
}
interface YenteHit {
id: string;
caption: string;
schema: string;
datasets: string[];
score: number;
}
interface SanctionsResponse {
verdict: 'HIT' | 'CLEAR';
name: string;
ofac: OfacHit;
yente: { source: string; hits: number; results: YenteHit[]; error: string | null };
checked_at: string;
}
type ActiveTab = 'lookup' | 'sanctions';
type Country = 'all' | 'ro' | 'de' | 'uk' | 'us';
const COUNTRY_LABELS: Record<Country, string> = {
all: 'Toate',
ro: '🇷🇴 România',
de: '🇩🇪 Germania',
uk: '🇬🇧 UK',
us: '🇺🇸 SUA',
};
const FLAG: Record<string, string> = { ro: '🇷🇴', de: '🇩🇪', uk: '🇬🇧', us: '🇺🇸' };
// ─── Component ────────────────────────────────────────────────────────────────
export default function OsintPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [tab, setTab] = useState<ActiveTab>('lookup');
// Company lookup state
const [lookupName, setLookupName] = useState('');
const [country, setCountry] = useState<Country>('all');
const [lookupResult, setLookupResult] = useState<CompanyLookupResponse | null>(null);
// Sanctions state
const [sanctionsName, setSanctionsName] = useState('');
const [sanctionsResult, setSanctionsResult] = useState<SanctionsResponse | null>(null);
const lookupMutation = useMutation({
mutationFn: (payload: { name: string; country: Country }) =>
apiFetch<CompanyLookupResponse>('/v1/dataquery/company/lookup', {
tenantId,
method: 'POST',
body: { name: payload.name, country: payload.country === 'all' ? undefined : payload.country },
}),
onSuccess: (data) => setLookupResult(data),
});
const sanctionsMutation = useMutation({
mutationFn: (name: string) =>
apiFetch<SanctionsResponse>('/v1/dataquery/sanctions', {
tenantId,
method: 'POST',
body: { name },
}),
onSuccess: (data) => setSanctionsResult(data),
});
return (
<div className="max-w-5xl">
{/* Header */}
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold text-ink">OSINT / Intelligence</h1>
<p className="text-sm text-ink-faint">
Căutare în registrele naționale RO · DE · UK · US și screening sancțiuni OFAC + OpenSanctions.
</p>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 border-b border-ink-line">
{(['lookup', 'sanctions'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === t
? 'border-b-2 border-bronze-deep text-bronze-deep'
: 'text-ink-faint hover:text-ink'
}`}
>
{t === 'lookup' ? '🏢 Registre Naționale' : '🚨 Screening Sancțiuni'}
</button>
))}
</div>
{/* ── Company Lookup Tab ─────────────────────────────────────────────── */}
{tab === 'lookup' && (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
if (lookupName.trim().length >= 2) {
lookupMutation.mutate({ name: lookupName.trim(), country });
}
}}
className="card mb-6 flex flex-col gap-3 p-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-ink-faint">Nume companie</label>
<input
type="text"
className="field"
placeholder="ex. Siemens, ING Bank, Electrica"
value={lookupName}
onChange={(e) => setLookupName(e.target.value)}
minLength={2}
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-ink-faint">Țară</label>
<select
className="field"
value={country}
onChange={(e) => setCountry(e.target.value as Country)}
>
{(Object.keys(COUNTRY_LABELS) as Country[]).map((c) => (
<option key={c} value={c}>
{COUNTRY_LABELS[c]}
</option>
))}
</select>
</div>
<button type="submit" disabled={lookupMutation.isPending} className="btn-primary shrink-0">
{lookupMutation.isPending ? 'Se caută…' : 'Caută'}
</button>
</form>
{lookupMutation.isError && (
<p className="mb-4 text-sm text-signal-danger">
{lookupMutation.error instanceof Error
? lookupMutation.error.message
: 'Eroare la căutare'}
</p>
)}
{lookupResult && (
<div>
<div className="mb-4 flex items-baseline gap-2">
<span className="font-display text-lg font-semibold text-ink">
{lookupResult.total_hits} rezultate
</span>
<span className="text-sm text-ink-faint">pentru &ldquo;{lookupResult.query}&rdquo;</span>
</div>
{lookupResult.errors.length > 0 && (
<div className="mb-4 rounded border border-signal-warn/30 bg-signal-warn/10 p-3 text-xs text-signal-warn">
Unele surse au eșuat: {lookupResult.errors.join('; ')}
</div>
)}
<div className="space-y-6">
{lookupResult.results.map((r) => (
<div key={r.country} className="card overflow-hidden">
<div className="flex items-center gap-2 border-b border-ink-line bg-paper-sunken px-4 py-2">
<span>{FLAG[r.country] ?? '🌐'}</span>
<span className="font-medium text-ink">{r.label}</span>
<span className="ml-auto text-xs text-ink-faint">{r.count} înregistrări</span>
</div>
{r.count === 0 ? (
<p className="px-4 py-3 text-sm text-ink-faint">Niciun rezultat.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-paper-sunken text-ink-faint">
<tr>
{r.columns.map((col) => (
<th key={col} className="px-3 py-2 text-left font-medium">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{r.rows.map((row, i) => (
<tr
key={i}
className="border-t border-ink-line/40 hover:bg-paper-sunken/50"
>
{(row as unknown[]).map((cell, j) => (
<td key={j} className="max-w-[200px] truncate px-3 py-2 text-ink">
{cell == null ? '—' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* ── Sanctions Tab ─────────────────────────────────────────────────── */}
{tab === 'sanctions' && (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
if (sanctionsName.trim().length >= 2) {
sanctionsMutation.mutate(sanctionsName.trim());
}
}}
className="card mb-6 flex gap-3 p-4"
>
<input
type="text"
className="field flex-1"
placeholder="Persoană sau entitate (ex. Gazprom, Ivan Petrov)"
value={sanctionsName}
onChange={(e) => setSanctionsName(e.target.value)}
minLength={2}
required
/>
<button
type="submit"
disabled={sanctionsMutation.isPending}
className="btn-primary shrink-0"
>
{sanctionsMutation.isPending ? 'Se verifică…' : 'Verifică'}
</button>
</form>
{sanctionsMutation.isError && (
<p className="mb-4 text-sm text-signal-danger">
{sanctionsMutation.error instanceof Error
? sanctionsMutation.error.message
: 'Eroare la verificare'}
</p>
)}
{sanctionsResult && (
<div className="space-y-4">
{/* Verdict banner */}
<div
className={`card flex items-center gap-4 p-5 ${
sanctionsResult.verdict === 'HIT'
? 'border-signal-danger/40 bg-signal-danger/10'
: 'border-signal-ok/40 bg-signal-ok/10'
}`}
>
<span className="text-3xl">
{sanctionsResult.verdict === 'HIT' ? '🚨' : '✅'}
</span>
<div>
<p
className={`text-lg font-semibold ${
sanctionsResult.verdict === 'HIT' ? 'text-signal-danger' : 'text-signal-ok'
}`}
>
{sanctionsResult.verdict === 'HIT' ? 'POTRIVIRE DETECTATĂ' : 'CLAR — fără potriviri'}
</p>
<p className="text-xs text-ink-faint">
Verificat la {new Date(sanctionsResult.checked_at).toLocaleString('ro-RO')} ·{' '}
{sanctionsResult.name}
</p>
</div>
</div>
{/* OFAC */}
<div className="card overflow-hidden">
<div className="flex items-center gap-2 border-b border-ink-line bg-paper-sunken px-4 py-2">
<span className="font-medium text-ink">🇺🇸 OFAC SDN List</span>
<span
className={`ml-auto text-xs font-medium ${
sanctionsResult.ofac.hits > 0 ? 'text-signal-danger' : 'text-signal-ok'
}`}
>
{sanctionsResult.ofac.hits} potriviri
</span>
</div>
{sanctionsResult.ofac.error ? (
<p className="px-4 py-3 text-xs text-signal-warn">
{sanctionsResult.ofac.error}
</p>
) : sanctionsResult.ofac.hits === 0 ? (
<p className="px-4 py-3 text-sm text-ink-faint">Nicio înregistrare în lista OFAC SDN.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-paper-sunken text-ink-faint">
<tr>
{sanctionsResult.ofac.columns.map((col) => (
<th key={col} className="px-3 py-2 text-left font-medium">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{sanctionsResult.ofac.rows.map((row, i) => (
<tr key={i} className="border-t border-ink-line/40 bg-signal-danger/5">
{(row as unknown[]).map((cell, j) => (
<td key={j} className="max-w-[180px] truncate px-3 py-2 text-ink">
{cell == null ? '—' : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* OpenSanctions */}
<div className="card overflow-hidden">
<div className="flex items-center gap-2 border-b border-ink-line bg-paper-sunken px-4 py-2">
<span className="font-medium text-ink">🌐 OpenSanctions (Yente)</span>
<span
className={`ml-auto text-xs font-medium ${
sanctionsResult.yente.hits > 0 ? 'text-signal-danger' : 'text-signal-ok'
}`}
>
{sanctionsResult.yente.hits} potriviri
</span>
</div>
{sanctionsResult.yente.error ? (
<p className="px-4 py-3 text-xs text-signal-warn">
{sanctionsResult.yente.error}
</p>
) : sanctionsResult.yente.hits === 0 ? (
<p className="px-4 py-3 text-sm text-ink-faint">
Nicio înregistrare în OpenSanctions.
</p>
) : (
<div className="space-y-0">
{sanctionsResult.yente.results.map((hit) => (
<div
key={hit.id}
className="flex items-start gap-3 border-t border-ink-line/40 bg-signal-danger/5 px-4 py-3"
>
<div className="flex-1">
<p className="font-medium text-ink">{hit.caption}</p>
<p className="text-xs text-ink-faint">
{hit.schema} · Seturi:{' '}
{Array.isArray(hit.datasets) ? hit.datasets.join(', ') : '—'}
</p>
</div>
<span className="shrink-0 rounded bg-signal-danger/20 px-2 py-0.5 text-xs font-medium text-signal-danger">
{typeof hit.score === 'number' ? `${Math.round(hit.score * 100)}%` : '—'}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}