feat(CC-054): add Market Intelligence page (financial signals by category)

This commit is contained in:
admin-valentin 2026-08-01 12:32:06 +00:00
parent 845b034d46
commit 9b5454db69

View file

@ -0,0 +1,167 @@
'use client';
import { useSession } from '../../../lib/session';
import { apiFetch } from '../../../lib/api';
import { useQuery } from '@tanstack/react-query';
interface Signal {
id: string;
category: string;
region: string;
source: string;
title: string;
summary: string;
rawValue?: string;
changePercent?: string;
unit?: string;
severity: string;
publishedAt: string;
}
const SEV_CLS: Record<string, string> = {
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
info: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
};
const CAT: Record<string, string> = {
macro: 'Macro', fx: 'FX / Valute', rates: 'Dobânzi',
commodity: 'Mărfuri', news: 'Știri Economice',
};
const REGION: Record<string, string> = {
ro: 'RO', de: 'DE', eu: 'EU', us: 'US', global: '—',
};
function fmt(v?: string, u?: string) {
if (!v) return null;
const n = parseFloat(v);
return isNaN(n) ? null : `${n.toLocaleString('ro-RO', { maximumFractionDigits: 2 })}${u ? ' ' + u : ''}`;
}
function Chip({ v }: { v?: string }) {
if (!v) return null;
const n = parseFloat(v);
if (isNaN(n)) return null;
return (
<span className={n >= 0 ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
{n >= 0 ? '+' : ''}{n.toFixed(2)}%
</span>
);
}
export default function MarketIntelligencePage() {
const { session } = useSession();
const { data: signals = [], isLoading } = useQuery<Signal[]>({
queryKey: ['financial-signals'],
enabled: !!session,
staleTime: 5 * 60_000,
queryFn: () =>
apiFetch('/v1/financial-intelligence/signals?limit=100', {
headers: {
Authorization: `Bearer ${session!.access_token}`,
'x-tenant-id': session!.tenant_id,
},
}),
});
const cats = [...new Set(signals.map((s) => s.category))].sort();
const byCategory = (cat: string) => signals.filter((s) => s.category === cat);
const critical = signals.filter((s) => s.severity === 'critical').length;
const warning = signals.filter((s) => s.severity === 'warning').length;
return (
<div className="space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold">Market Intelligence</h1>
<p className="text-sm text-muted-foreground mt-1">
Semnale economice live World Bank · ECB · Eurostat · FRED
</p>
</div>
<div className="flex gap-2 text-sm">
{critical > 0 && (
<span className="px-3 py-1 rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 font-medium">
{critical} critice
</span>
)}
{warning > 0 && (
<span className="px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 font-medium">
{warning} atenționări
</span>
)}
<span className="px-3 py-1 rounded-full bg-muted text-muted-foreground">
{signals.length} total
</span>
</div>
</div>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
Se încarcă semnalele...
</div>
)}
{!isLoading && signals.length === 0 && (
<div className="rounded-xl border border-dashed p-12 text-center space-y-3">
<p className="text-lg font-medium">Fără semnale economice</p>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Importă workflow-ul n8n &ldquo;08 Economic Data API&rdquo; în n8n
(rulează la fiecare 6h și populează această pagină automat).
</p>
<div className="text-xs bg-muted rounded-lg p-4 text-left inline-block mt-2">
<p className="font-mono">Fișier: /root/ceo-os-economic-data-n8n.json</p>
<p className="text-muted-foreground mt-1">POST /v1/financial-intelligence/ingest</p>
</div>
</div>
)}
{cats.map((cat) => (
<section key={cat}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
{CAT[cat] ?? cat}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{byCategory(cat).map((s) => (
<article key={s.id}
className="rounded-xl border bg-card p-4 flex flex-col gap-2 hover:shadow-sm transition-shadow">
<div className="flex items-start gap-2">
<span className="flex-1 font-medium text-sm leading-snug">{s.title}</span>
<span className={`text-xs px-2 py-0.5 rounded-full shrink-0 font-medium ${SEV_CLS[s.severity] ?? SEV_CLS.info}`}>
{s.severity}
</span>
</div>
{(s.rawValue || s.changePercent) && (
<div className="flex items-center gap-3">
{s.rawValue && (
<span className="text-2xl font-bold tabular-nums leading-none">
{fmt(s.rawValue, s.unit)}
</span>
)}
<Chip v={s.changePercent} />
</div>
)}
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{s.summary}</p>
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1 border-t">
<span>{s.source}</span>
<div className="flex items-center gap-1.5">
{s.region !== 'global' && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs">
{REGION[s.region] ?? s.region}
</span>
)}
<span>{new Date(s.publishedAt).toLocaleDateString('ro-RO')}</span>
</div>
</div>
</article>
))}
</div>
</section>
))}
</div>
);
}