feat(CC-054): add Market Intelligence page (financial signals by category)
This commit is contained in:
parent
845b034d46
commit
9b5454db69
1 changed files with 167 additions and 0 deletions
167
src/app/dashboard/market/page.tsx
Normal file
167
src/app/dashboard/market/page.tsx
Normal 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 “08 Economic Data API” î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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue