feat(FE): Intelligence Hub page — ClickHouse status, safe queries, legislation search
This commit is contained in:
parent
ec5bd7d7dc
commit
daf5f43f01
1 changed files with 132 additions and 224 deletions
|
|
@ -1,252 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import type { SavedSegment, ResearchBrief, FinancialSignal } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
const B = '/dashboard';
|
||||
interface ChResult { data: Record<string, unknown>[]; rows: number; elapsed: number; error?: string; }
|
||||
interface ChStatus { ok: boolean; version?: string; error?: string; }
|
||||
|
||||
const SEV_CFG = {
|
||||
critical: { borderClass: 'border-l-signal-danger', badgeClass: 'bg-signal-danger text-white', icon: '!' },
|
||||
warning: { borderClass: 'border-l-signal-warn', badgeClass: 'bg-signal-warn text-white', icon: '⚠' },
|
||||
info: { borderClass: 'border-l-bronze-deep', badgeClass: 'bg-bronze-wash text-bronze-deep', icon: 'i' },
|
||||
} as const;
|
||||
const SAFE_QUERIES = [
|
||||
{ key: 'databases', label: 'Databases disponibile', params: {} },
|
||||
{ key: 'tables', label: 'Tabele (toate)', params: {} },
|
||||
];
|
||||
|
||||
const CAT_LABEL: Record<string, string> = {
|
||||
macro: 'Macro', fx: 'Valute', rates: 'Rate', news: 'Știri', commodity: 'Materii prime',
|
||||
};
|
||||
|
||||
export default function IntelligenceOverviewPage() {
|
||||
export default function IntelligenceHubPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const opts = { enabled: Boolean(tenantId), staleTime: 60_000 };
|
||||
const [legislationQ, setLegislationQ] = useState('');
|
||||
const [result, setResult] = useState<ChResult | null>(null);
|
||||
const [activeQuery, setActiveQuery] = useState('');
|
||||
|
||||
const { data: segments = [], isLoading: loadingSegs } = useQuery({
|
||||
queryKey: ['segments-intel', tenantId],
|
||||
queryFn: () => apiFetch<SavedSegment[]>('/v1/segments', { tenantId }),
|
||||
...opts,
|
||||
const { data: status, isLoading: loadStatus } = useQuery({
|
||||
queryKey: ['ch-status', tenantId],
|
||||
queryFn: () => apiFetch<ChStatus>('/v1/intelligence/status', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: briefs = [], isLoading: loadingBriefs } = useQuery({
|
||||
queryKey: ['briefs-intel', tenantId],
|
||||
queryFn: () => apiFetch<ResearchBrief[]>('/v1/research-briefs', { tenantId }),
|
||||
...opts,
|
||||
const runQueryMut = useMutation({
|
||||
mutationFn: (queryKey: string) =>
|
||||
apiFetch<ChResult>('/v1/intelligence/query', {
|
||||
tenantId, method: 'POST', body: { queryKey },
|
||||
}),
|
||||
onSuccess: (data, key) => { setResult(data); setActiveQuery(key); },
|
||||
});
|
||||
|
||||
const { data: signals = [], isLoading: loadingSignals } = useQuery({
|
||||
queryKey: ['financial-signals', tenantId],
|
||||
queryFn: () => apiFetch<FinancialSignal[]>('/v1/financial-intelligence/signals?limit=20', { tenantId }),
|
||||
...opts,
|
||||
staleTime: 300_000,
|
||||
const searchMut = useMutation({
|
||||
mutationFn: (q: string) =>
|
||||
apiFetch<ChResult>(`/v1/intelligence/legislation?q=${encodeURIComponent(q)}`, { tenantId }),
|
||||
onSuccess: (data) => { setResult(data); setActiveQuery('legislation'); },
|
||||
});
|
||||
|
||||
const isLoading = loadingSegs || loadingBriefs || loadingSignals;
|
||||
|
||||
const recentBriefs = [...briefs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
).slice(0, 4);
|
||||
|
||||
const criticalSignals = signals.filter((s) => s.severity === 'critical');
|
||||
const warningSignals = signals.filter((s) => s.severity === 'warning');
|
||||
const lastSignalAt = signals[0]?.publishedAt;
|
||||
const chOk = status?.ok;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Intelligence Overview</h1>
|
||||
<p className="mt-1 text-sm text-ink-faint">
|
||||
Motorul de intelligence B2B + semnale de piață financiară internațională.
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Intelligence Hub</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">ClickHouse · {status?.version ?? '…'} · 147M+ rânduri</p>
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div className={`card p-4 flex items-center gap-3 ${chOk ? 'border-signal-ok/30 bg-signal-ok/5' : 'border-signal-danger/30 bg-signal-danger/5'}`}>
|
||||
<span className={`text-2xl ${loadStatus ? 'animate-pulse' : ''}`}>{chOk ? '🟢' : '🔴'}</span>
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${chOk ? 'text-signal-ok' : 'text-signal-danger'}`}>
|
||||
ClickHouse {chOk ? 'conectat' : 'indisponibil'}
|
||||
</p>
|
||||
<p className="text-xs text-ink-faint">
|
||||
{status?.error ?? `supersrv:8123 · DB: ${process.env.NEXT_PUBLIC_CH_DB ?? 'default'}`}
|
||||
</p>
|
||||
</div>
|
||||
{!chOk && (
|
||||
<div className="ml-auto text-[10px] text-ink-faint text-right">
|
||||
<p>Setează în Coolify:</p>
|
||||
<code className="font-mono">CLICKHOUSE_URL, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick queries */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Query-uri predefinite (safe)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SAFE_QUERIES.map((q) => (
|
||||
<button key={q.key}
|
||||
onClick={() => runQueryMut.mutate(q.key)}
|
||||
disabled={!chOk || runQueryMut.isPending}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-40 ${activeQuery === q.key ? 'bg-primary text-white border-primary' : 'bg-background text-ink border-border hover:border-primary/40'}`}>
|
||||
{q.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legislation search */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Căutare legislație DE + ESCO</p>
|
||||
<div className="flex gap-2">
|
||||
<input placeholder="ex: Steuerberatung, Mindestlohn, ESCO-Code…"
|
||||
value={legislationQ}
|
||||
onChange={(e) => setLegislationQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && legislationQ.trim() && searchMut.mutate(legislationQ)}
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<button onClick={() => searchMut.mutate(legislationQ)}
|
||||
disabled={!legislationQ.trim() || !chOk || searchMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-white disabled:opacity-50">
|
||||
{searchMut.isPending ? '…' : 'Caută'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
Caută în tabelul <code>default.legislation</code> din ClickHouse.
|
||||
Dacă tabela nu există — importă datele cu Data Ingest Toolkit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-ink-faint">Se încarcă…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* ── MARKET SIGNALS ─────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Market Intelligence
|
||||
{(criticalSignals.length > 0 || warningSignals.length > 0) && (
|
||||
<span className="ml-2 rounded-full bg-signal-danger px-2 py-0.5 text-[10px] text-white">
|
||||
{criticalSignals.length + warningSignals.length} alerte
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{lastSignalAt && (
|
||||
<span className="text-[11px] text-ink-line">
|
||||
Actualizat: {new Date(lastSignalAt).toLocaleString('ro-RO', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-ink">
|
||||
Rezultate — {result.rows} rânduri în {result.elapsed}ms
|
||||
</p>
|
||||
<button onClick={() => setResult(null)} className="text-xs text-ink-faint hover:text-ink">✕ Închide</button>
|
||||
</div>
|
||||
{result.error ? (
|
||||
<p className="text-sm text-signal-danger font-mono">{result.error}</p>
|
||||
) : result.data.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">Niciun rezultat.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
{Object.keys(result.data[0]).map((k) => (
|
||||
<th key={k} className="px-3 py-2 text-left font-medium text-ink-faint">{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{result.data.slice(0, 50).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
{Object.values(row).map((v, j) => (
|
||||
<td key={j} className="px-3 py-2 text-ink max-w-xs truncate">{String(v)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{result.data.length > 50 && (
|
||||
<p className="text-[10px] text-ink-faint text-center pt-2">+{result.data.length - 50} rânduri ascunse</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signals.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm font-medium text-ink">Niciun semnal de piață disponibil</p>
|
||||
<p className="mt-1 text-xs text-ink-faint">
|
||||
Semnalele sunt generate automat de n8n din World Bank, IMF și Eurostat.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] font-mono text-ink-line">
|
||||
Importați workflow-ul n8n din /dashboard/data sau contactați administratorul.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{signals.slice(0, 8).map((sig) => {
|
||||
const cfg = SEV_CFG[sig.severity as keyof typeof SEV_CFG] ?? SEV_CFG.info;
|
||||
return (
|
||||
<article
|
||||
key={sig.id}
|
||||
className={`card border-l-2 ${cfg.borderClass} px-4 py-3`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${cfg.badgeClass}`}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<span className="rounded bg-paper-sunken px-1.5 py-0.5 text-[10px] text-ink-faint capitalize">
|
||||
{CAT_LABEL[sig.category] ?? sig.category}
|
||||
</span>
|
||||
<span className="rounded bg-paper-sunken px-1.5 py-0.5 text-[10px] text-ink-faint uppercase">
|
||||
{sig.region}
|
||||
</span>
|
||||
{sig.niche && (
|
||||
<span className="rounded bg-bronze-wash px-1.5 py-0.5 text-[10px] font-medium text-bronze-deep capitalize">
|
||||
{sig.niche}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm font-semibold text-ink leading-snug">
|
||||
{sig.title}
|
||||
</p>
|
||||
{sig.rawValue != null && (
|
||||
<p className="mt-0.5 font-mono text-xs text-ink-faint">
|
||||
{Number(sig.rawValue).toFixed(2)}{sig.unit ? ` ${sig.unit}` : ''}
|
||||
{sig.changePercent != null && (
|
||||
<span className={Number(sig.changePercent) >= 0 ? 'text-signal-ok ml-1' : 'text-signal-danger ml-1'}>
|
||||
({Number(sig.changePercent) >= 0 ? '+' : ''}{Number(sig.changePercent).toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-ink-faint leading-relaxed line-clamp-2">{sig.summary}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-[10px] text-ink-line font-mono uppercase">{sig.source}</p>
|
||||
<p className="text-[10px] text-ink-line">
|
||||
{new Date(sig.publishedAt).toLocaleDateString('ro-RO')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{signals.length > 8 && (
|
||||
<div className="card p-4 text-center text-xs text-ink-faint lg:col-span-2">
|
||||
+{signals.length - 8} semnale suplimentare disponibile
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── B2B INTELLIGENCE STATS ─────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Segmente salvate', value: segments.length, href: `${B}/segments` },
|
||||
{ label: 'Research Briefs', value: briefs.length, href: `${B}/research` },
|
||||
{ label: 'Companii B2B', value: '147M+', href: `${B}/companies` },
|
||||
{ label: 'Semnale piață', value: signals.length, href: '#' },
|
||||
].map((s) => (
|
||||
<Link key={s.label} href={s.href} className="card p-5 hover:border-bronze-deep transition-colors">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">{s.label}</p>
|
||||
<p className="mt-1 font-display text-3xl font-semibold text-ink">{s.value}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── B2B MODULES ────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Research recente</h2>
|
||||
<Link href={`${B}/research`} className="text-xs text-bronze-deep hover:underline">Toate →</Link>
|
||||
</div>
|
||||
{recentBriefs.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">Niciun brief creat.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{recentBriefs.map((b) => (
|
||||
<li key={b.id} className="border-b border-paper-sunken pb-3 last:border-0 last:pb-0">
|
||||
<p className="text-sm font-medium text-ink">{b.title}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="rounded bg-paper-sunken px-1.5 py-0.5 text-[10px] font-medium capitalize text-ink-faint">{b.status}</span>
|
||||
<span className="text-[11px] text-ink-line">{new Date(b.createdAt).toLocaleDateString('ro-RO')}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Segmente active</h2>
|
||||
<Link href={`${B}/segments`} className="text-xs text-bronze-deep hover:underline">Gestionează →</Link>
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">Niciun segment salvat.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{segments.slice(0, 6).map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between">
|
||||
<p className="min-w-0 truncate text-sm text-ink">{s.name}</p>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-paper-sunken px-2 py-0.5 font-mono text-[10px] text-ink-faint">
|
||||
limit {s.resultLimit}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{segments.length > 6 && (
|
||||
<li className="text-xs text-ink-faint">+{segments.length - 6} mai multe</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Capability map */}
|
||||
<section className="card p-5 lg:col-span-2">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Capabilități Intelligence
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Company Search', status: 'live', href: `${B}/companies` },
|
||||
{ label: 'Saved Segments', status: 'live', href: `${B}/segments` },
|
||||
{ label: 'Research Briefs', status: 'live', href: `${B}/research` },
|
||||
{ label: 'Market Signals', status: signals.length > 0 ? 'live' : 'setup', href: '#' },
|
||||
{ label: 'People Intelligence', status: 'a3', href: '#' },
|
||||
{ label: 'Market Intelligence', status: 'a4', href: '#' },
|
||||
{ label: 'Insights Engine', status: 'a3', href: '#' },
|
||||
{ label: 'Digital Twin', status: 'a4', href: '#' },
|
||||
].map((cap) => (
|
||||
<div key={cap.label} className={`rounded-lg border px-3 py-2 ${cap.status === 'live' ? 'border-signal-ok/30 bg-signal-ok/5' : cap.status === 'setup' ? 'border-signal-warn/30 bg-signal-warn/5' : 'border-paper-sunken bg-paper-sunken/50'}`}>
|
||||
<p className="text-xs font-medium text-ink">{cap.label}</p>
|
||||
<span className={`mt-0.5 inline-block text-[10px] font-semibold uppercase ${cap.status === 'live' ? 'text-signal-ok' : cap.status === 'setup' ? 'text-signal-warn' : 'text-ink-line'}`}>
|
||||
{cap.status === 'live' ? '● live' : cap.status === 'setup' ? '○ setup' : cap.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture note */}
|
||||
<div className="card p-4 bg-primary/5 border-primary/20 space-y-1">
|
||||
<p className="text-xs font-semibold text-ink">Arhitectură conexiune C2</p>
|
||||
<code className="text-[10px] text-ink-faint block">
|
||||
ceo-web → /v1/intelligence/* → ceo-api IntelligenceService → ClickHouse HTTP :8123 (supersrv)
|
||||
</code>
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
Variabile necesare în Coolify → ceo-api env: CLICKHOUSE_URL · CLICKHOUSE_USER · CLICKHOUSE_PASSWORD · CLICKHOUSE_DATABASE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue