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:
parent
e9ef81d272
commit
a0c7fd57f9
1 changed files with 398 additions and 0 deletions
398
src/app/dashboard/osint/page.tsx
Normal file
398
src/app/dashboard/osint/page.tsx
Normal 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 “{lookupResult.query}”</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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue