- MarketEvents: date presets (7j/30j/3m/6m/1an/Tout/Perso), instrument impact filter (ticker + min score + direction → auto-sort by inst score), sort controls (date/score/nom + asc/desc), clear-all button, count bar - Market events list endpoint: full SQL JOIN rewrite supporting instrument filter, date range, origin, sort_by=instrument_score - Disable auto-bootstrap on startup (macro/eco/categories) — manual only - CycleActions: bootstrap group (Macro/Eco/Categories) with force checkbox, grouped layout (detection / bootstrap / data / ai / portfolio) - cycle_actions router: /bootstrap-macro, /bootstrap-eco, /bootstrap-categories endpoints + group field on all action catalogue entries - InstrumentChart: greedy row placement for star labels (4 rows × 20px) to eliminate horizontal overlap; labels now inline (★ + text) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1404 lines
65 KiB
TypeScript
1404 lines
65 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import {
|
||
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
|
||
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
|
||
SlidersHorizontal, BarChart2, Star, Users
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface SourceRef {
|
||
title: string
|
||
source: string
|
||
url: string
|
||
date: string
|
||
original_score: number
|
||
}
|
||
|
||
interface InstrumentImpact {
|
||
id: number
|
||
instrument_id: string
|
||
impact_score: number
|
||
direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome'
|
||
rationale: string
|
||
confidence: number
|
||
ai_generated: number
|
||
manually_adjusted: number
|
||
adjusted_score: number | null
|
||
adjusted_direction: string | null
|
||
override_rationale: string
|
||
}
|
||
|
||
interface MarketEvent {
|
||
id: number
|
||
name: string
|
||
start_date: string
|
||
end_date: string | null
|
||
level: 'long' | 'medium' | 'short'
|
||
category: string
|
||
sub_type: string
|
||
description: string
|
||
market_impact: string
|
||
affected_assets: string[]
|
||
impact_score: number
|
||
absorption_pct: number | null
|
||
source_refs: SourceRef[]
|
||
origin?: string
|
||
impacts?: InstrumentImpact[]
|
||
evaluated?: boolean
|
||
}
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const CAT_COLORS: Record<string, string> = {
|
||
event_calendar: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
|
||
geopolitical: 'bg-red-500/20 text-red-300 border-red-500/30',
|
||
fundamental: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
|
||
report: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
|
||
sentiment: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
|
||
technical: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
|
||
}
|
||
const CAT_DOT: Record<string, string> = {
|
||
event_calendar: 'bg-amber-400',
|
||
geopolitical: 'bg-red-400',
|
||
fundamental: 'bg-emerald-400',
|
||
report: 'bg-blue-400',
|
||
sentiment: 'bg-purple-400',
|
||
technical: 'bg-cyan-400',
|
||
}
|
||
const LEVEL_COLORS: Record<string, string> = {
|
||
long: 'text-purple-400',
|
||
medium: 'text-amber-400',
|
||
short: 'text-emerald-400',
|
||
}
|
||
const ORIGIN_META: Record<string, { label: string; icon: string; color: string; description: string }> = {
|
||
bootstrap_macro: { label: 'Bootstrap macro', icon: '📚', color: 'text-slate-400', description: 'Données historiques macro/géopolitiques chargées au démarrage' },
|
||
bootstrap_eco: { label: 'Bootstrap éco', icon: '📅', color: 'text-amber-400', description: 'Calendrier économique FRED historique (FOMC, CPI, NFP…)' },
|
||
bootstrap_ma: { label: 'Bootstrap MA', icon: '📈', color: 'text-cyan-400', description: 'Signal technique MA détecté sur données historiques (yfinance)' },
|
||
bootstrap_legacy: { label: 'Bootstrap legacy', icon: '🗄️', color: 'text-slate-500', description: 'Données initiales (bootstrap, origine exacte inconnue)' },
|
||
detector_news: { label: 'News RSS', icon: '📰', color: 'text-red-400', description: 'Généré depuis un flux RSS — validé par IA (GPT-4o-mini)' },
|
||
detector_eco: { label: 'Surprise FRED', icon: '📊', color: 'text-amber-400', description: 'Surprise économique FRED détectée par le cycle (z-score > seuil)' },
|
||
detector_technical: { label: 'Signal technique', icon: '📐', color: 'text-cyan-400', description: 'Croisement MA50/MA100/MA200 détecté automatiquement (yfinance)' },
|
||
detector_report: { label: 'Rapport instit.', icon: '📋', color: 'text-blue-400', description: 'Rapport institutionnel haute importance (COT, EIA, Fed…)' },
|
||
manual: { label: 'Manuel', icon: '✏️', color: 'text-emerald-400', description: 'Créé manuellement dans l\'interface' },
|
||
unknown: { label: 'Inconnu', icon: '❓', color: 'text-slate-600', description: 'Origine non tracée (données antérieures au versioning)' },
|
||
}
|
||
|
||
const DIR_COLORS: Record<string, string> = {
|
||
bullish: 'text-emerald-400',
|
||
bearish: 'text-red-400',
|
||
neutral: 'text-slate-400',
|
||
depends_on_outcome: 'text-amber-400',
|
||
}
|
||
const DIR_ICONS: Record<string, string> = {
|
||
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
|
||
}
|
||
|
||
const CATEGORIES = ['event_calendar', 'geopolitical', 'fundamental', 'report', 'sentiment', 'technical']
|
||
const LEVELS = ['long', 'medium', 'short']
|
||
const ALL_INSTRUMENTS = [
|
||
'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT',
|
||
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD',
|
||
]
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
|
||
const pct = Math.round((score / max) * 100)
|
||
const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500'
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="text-xs text-slate-400 w-8 text-right">{score.toFixed(2)}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CatBadge({ cat }: { cat: string }) {
|
||
return (
|
||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium', CAT_COLORS[cat] ?? 'bg-slate-700/50 text-slate-400 border-slate-700')}>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full', CAT_DOT[cat] ?? 'bg-slate-400')} />
|
||
{cat.replace('_', ' ')}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── Instrument impact row (inline editable) ───────────────────────────────────
|
||
|
||
function ImpactRow({
|
||
imp, eventId, onUpdated, onDeleted,
|
||
}: {
|
||
imp: InstrumentImpact
|
||
eventId: number
|
||
onUpdated: () => void
|
||
onDeleted: () => void
|
||
}) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score))
|
||
const [dir, setDir] = useState(imp.adjusted_direction || imp.direction)
|
||
const [note, setNote] = useState(imp.override_rationale || '')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const effectiveScore = imp.adjusted_score ?? imp.impact_score
|
||
const effectiveDir = imp.adjusted_direction || imp.direction
|
||
|
||
const save = async () => {
|
||
setSaving(true)
|
||
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }),
|
||
})
|
||
setSaving(false)
|
||
setEditing(false)
|
||
onUpdated()
|
||
}
|
||
|
||
const del = async () => {
|
||
if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return
|
||
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' })
|
||
onDeleted()
|
||
}
|
||
|
||
return (
|
||
<div className="border-b border-slate-800/60 last:border-0">
|
||
<div className="flex items-center gap-3 py-2 px-3 hover:bg-slate-800/20">
|
||
{/* Ticker */}
|
||
<span className="font-mono text-sm text-slate-200 w-24 shrink-0">{imp.instrument_id}</span>
|
||
{/* Direction */}
|
||
<span className={clsx('text-sm font-bold w-6 shrink-0', DIR_COLORS[effectiveDir])}>
|
||
{DIR_ICONS[effectiveDir] ?? '●'}
|
||
</span>
|
||
{/* Score bar */}
|
||
<div className="w-28 shrink-0">
|
||
<ScoreBar score={effectiveScore} />
|
||
</div>
|
||
{/* Confidence */}
|
||
<span className="text-xs text-slate-500 w-10 shrink-0">{Math.round(imp.confidence * 100)}%</span>
|
||
{/* Rationale */}
|
||
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{imp.rationale}</span>
|
||
{/* AI badge */}
|
||
{imp.ai_generated ? (
|
||
<span className="text-xs text-blue-400 shrink-0">AI</span>
|
||
) : (
|
||
<span className="text-xs text-slate-600 shrink-0">manual</span>
|
||
)}
|
||
{imp.manually_adjusted ? <span className="text-xs text-amber-400 shrink-0">adj.</span> : null}
|
||
{/* Actions */}
|
||
<button onClick={() => setEditing(x => !x)} className="text-slate-500 hover:text-slate-300 shrink-0"><Edit3 size={12} /></button>
|
||
<button onClick={del} className="text-slate-600 hover:text-red-400 shrink-0"><Trash2 size={12} /></button>
|
||
</div>
|
||
{editing && (
|
||
<div className="px-3 pb-3 grid grid-cols-3 gap-2 bg-slate-800/30">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Score ajusté</span>
|
||
<input type="range" min="0" max="1" step="0.05"
|
||
value={score} onChange={e => setScore(e.target.value)}
|
||
className="w-full accent-amber-400" />
|
||
<span className="text-xs text-center text-amber-300">{(+score).toFixed(2)}</span>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Direction</span>
|
||
<select value={dir} onChange={e => setDir(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{['bullish','bearish','neutral','depends_on_outcome'].map(d => (
|
||
<option key={d} value={d}>{d}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-slate-500">Note</span>
|
||
<input value={note} onChange={e => setNote(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm"
|
||
placeholder="Raison de l'ajustement..." />
|
||
</label>
|
||
<div className="col-span-3 flex gap-2 mt-1">
|
||
<button onClick={save} disabled={saving}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-3 py-1">
|
||
{saving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle size={11} />}
|
||
Sauvegarder
|
||
</button>
|
||
<button onClick={() => setEditing(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Add instrument panel ──────────────────────────────────────────────────────
|
||
|
||
function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [ticker, setTicker] = useState('')
|
||
const [score, setScore] = useState(0.5)
|
||
const [dir, setDir] = useState('neutral')
|
||
const [rationale, setRat] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const submit = async () => {
|
||
if (!ticker) return
|
||
setSaving(true)
|
||
await fetch(`/api/market-events/${eventId}/impacts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }),
|
||
})
|
||
setSaving(false)
|
||
setOpen(false)
|
||
setTicker('')
|
||
setRat('')
|
||
onAdded()
|
||
}
|
||
|
||
if (!open) {
|
||
return (
|
||
<button onClick={() => setOpen(true)}
|
||
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-emerald-400 py-2 px-3 border-t border-slate-800/60">
|
||
<Plus size={12} /> Ajouter instrument
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="border-t border-slate-800/60 px-3 py-3 bg-slate-800/30 grid grid-cols-4 gap-2">
|
||
<select value={ticker} onChange={e => setTicker(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm col-span-1">
|
||
<option value="">Ticker...</option>
|
||
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<select value={dir} onChange={e => setDir(e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
|
||
</select>
|
||
<div className="flex flex-col gap-0.5">
|
||
<input type="range" min="0" max="1" step="0.05" value={score}
|
||
onChange={e => setScore(+e.target.value)} className="accent-emerald-400" />
|
||
<span className="text-xs text-center text-emerald-300">{score.toFixed(2)}</span>
|
||
</div>
|
||
<input value={rationale} onChange={e => setRat(e.target.value)} placeholder="Rationale..."
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
|
||
<div className="col-span-4 flex gap-2">
|
||
<button onClick={submit} disabled={saving || !ticker}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white text-xs rounded px-3 py-1">
|
||
{saving ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Ajouter
|
||
</button>
|
||
<button onClick={() => setOpen(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Event detail panel (right side) ──────────────────────────────────────────
|
||
|
||
function EventDetail({
|
||
event, onClose, onUpdated, onDeleted,
|
||
}: {
|
||
event: MarketEvent
|
||
onClose: () => void
|
||
onUpdated: (ev: MarketEvent) => void
|
||
onDeleted: (id: number) => void
|
||
}) {
|
||
const [detail, setDetail] = useState<MarketEvent>(event)
|
||
const [evaluating, setEval] = useState(false)
|
||
const [evalMsg, setEvalMsg] = useState<string | null>(null)
|
||
const [editing, setEditing] = useState(false)
|
||
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const loadDetail = useCallback(async () => {
|
||
const r = await fetch(`/api/market-events/${event.id}`)
|
||
if (r.ok) {
|
||
const d = await r.json()
|
||
setDetail(d)
|
||
}
|
||
}, [event.id])
|
||
|
||
useEffect(() => { loadDetail() }, [loadDetail])
|
||
|
||
const evaluate = async (force = false) => {
|
||
setEval(true)
|
||
setEvalMsg(null)
|
||
try {
|
||
const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' })
|
||
const d = await r.json()
|
||
if (!r.ok) throw new Error(d.detail || r.statusText)
|
||
setEvalMsg(`✓ ${d.n_instruments} instruments évalués`)
|
||
await loadDetail()
|
||
} catch (e: any) {
|
||
setEvalMsg(`✗ ${e.message}`)
|
||
} finally {
|
||
setEval(false)
|
||
}
|
||
}
|
||
|
||
const saveEdit = async () => {
|
||
setSaving(true)
|
||
const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] }
|
||
const r = await fetch(`/api/market-events/${event.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
setSaving(false)
|
||
if (r.ok) {
|
||
setEditing(false)
|
||
await loadDetail()
|
||
onUpdated({ ...detail, ...editData } as MarketEvent)
|
||
}
|
||
}
|
||
|
||
const del = async () => {
|
||
if (!confirm(`Supprimer "${detail.name}" ?`)) return
|
||
await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' })
|
||
onDeleted(event.id)
|
||
onClose()
|
||
}
|
||
|
||
const impacts = detail.impacts || []
|
||
const evalScore = detail.impact_score
|
||
|
||
return (
|
||
<div className="flex flex-col h-full overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-start gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
|
||
<div className="flex-1 min-w-0">
|
||
{editing ? (
|
||
<input
|
||
defaultValue={detail.name}
|
||
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
|
||
className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full"
|
||
/>
|
||
) : (
|
||
<h2 className="text-slate-100 font-bold text-sm leading-snug">{detail.name}</h2>
|
||
)}
|
||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||
<CatBadge cat={detail.category} />
|
||
{detail.sub_type && <span className="text-xs text-slate-500">{detail.sub_type}</span>}
|
||
<span className={clsx('text-xs font-semibold', LEVEL_COLORS[detail.level])}>{detail.level}</span>
|
||
<span className="text-xs text-slate-500">{detail.start_date}</span>
|
||
{detail.end_date && <span className="text-xs text-slate-600">→ {detail.end_date}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<button onClick={() => { setEditing(x => !x); setEditData({}) }}
|
||
className={clsx('p-1.5 rounded hover:bg-slate-700/50', editing ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
|
||
<Edit3 size={14} />
|
||
</button>
|
||
<button onClick={del} className="p-1.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-900/20">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<button onClick={onClose} className="p-1.5 rounded text-slate-500 hover:text-slate-300 hover:bg-slate-700/50">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scrollable body */}
|
||
<div className="flex-1 overflow-y-auto space-y-4 px-5 py-4">
|
||
|
||
{/* Impact score + absorption */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="bg-slate-800/40 rounded-lg p-3">
|
||
<div className="text-xs text-slate-500 mb-1">Score impact</div>
|
||
<ScoreBar score={evalScore} />
|
||
</div>
|
||
{detail.absorption_pct != null && (
|
||
<div className="bg-slate-800/40 rounded-lg p-3">
|
||
<div className="text-xs text-slate-500 mb-1">Absorption marché</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${detail.absorption_pct}%` }} />
|
||
</div>
|
||
<span className="text-xs text-blue-300">{detail.absorption_pct}%</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{editing ? (
|
||
<textarea
|
||
defaultValue={detail.description}
|
||
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
|
||
rows={3}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-300 text-sm resize-none"
|
||
/>
|
||
) : detail.description ? (
|
||
<p className="text-sm text-slate-300 leading-relaxed">{detail.description}</p>
|
||
) : null}
|
||
|
||
{editing ? (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Catégorie</div>
|
||
<select defaultValue={detail.category}
|
||
onChange={e => setEditData(d => ({ ...d, category: e.target.value }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Niveau</div>
|
||
<select defaultValue={detail.level}
|
||
onChange={e => setEditData(d => ({ ...d, level: e.target.value as any }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Score impact</div>
|
||
<input type="number" step="0.05" min="0" max="1" defaultValue={detail.impact_score}
|
||
onChange={e => setEditData(d => ({ ...d, impact_score: +e.target.value }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{editing && (
|
||
<div className="flex gap-2">
|
||
<button onClick={saveEdit} disabled={saving}
|
||
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg px-4 py-1.5">
|
||
{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle size={13} />} Sauvegarder
|
||
</button>
|
||
<button onClick={() => setEditing(false)} className="text-sm text-slate-500 hover:text-slate-300 px-3">Annuler</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Origin + Sources */}
|
||
<div>
|
||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Provenance</div>
|
||
{/* Origin badge */}
|
||
{(() => {
|
||
const meta = ORIGIN_META[detail.origin ?? 'unknown'] ?? ORIGIN_META['unknown']
|
||
return (
|
||
<div className="flex items-start gap-2 bg-slate-800/50 rounded-lg px-3 py-2 mb-2 border border-slate-700/30">
|
||
<span className="text-base shrink-0">{meta.icon}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className={`text-xs font-semibold ${meta.color}`}>{meta.label}</div>
|
||
<div className="text-xs text-slate-500 mt-0.5">{meta.description}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
{/* Source refs */}
|
||
{(detail.source_refs?.length ?? 0) > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{detail.source_refs!.map((ref, i) => (
|
||
<div key={i} className="flex items-start gap-2 bg-slate-800/30 rounded-lg px-3 py-2 border border-slate-800/60">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs text-slate-200 font-medium">{ref.title}</div>
|
||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||
<span className="text-xs text-slate-500">{ref.source}</span>
|
||
<span className="text-xs text-slate-600">{ref.date}</span>
|
||
{ref.original_score > 0 && (
|
||
<span className="text-xs text-slate-600">
|
||
score brut: <span className="text-slate-400">{ref.original_score.toFixed(2)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{ref.url && (
|
||
<a href={ref.url} target="_blank" rel="noreferrer"
|
||
className="text-slate-500 hover:text-blue-400 shrink-0 mt-0.5">
|
||
<ExternalLink size={12} />
|
||
</a>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-slate-600 italic px-3 py-2 bg-slate-800/20 rounded-lg border border-slate-800/40">
|
||
Aucune source liée — cet événement ne référence pas de document ou d'article spécifique.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Instrument impacts */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs text-slate-500 uppercase tracking-wide">
|
||
Impacts instruments
|
||
<span className="text-slate-600 ml-1.5 normal-case">({impacts.length})</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{evalMsg && (
|
||
<span className={clsx('text-xs', evalMsg.startsWith('✓') ? 'text-emerald-400' : 'text-red-400')}>
|
||
{evalMsg}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => evaluate(impacts.length > 0)}
|
||
disabled={evaluating}
|
||
className="flex items-center gap-1.5 text-xs bg-blue-700/40 hover:bg-blue-700/60 border border-blue-700/50 text-blue-300 rounded px-3 py-1"
|
||
>
|
||
{evaluating ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
|
||
{impacts.length > 0 ? 'Ré-analyser' : 'Analyser IA'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{impacts.length === 0 ? (
|
||
<div className="text-xs text-slate-600 italic py-3 px-3 bg-slate-800/30 rounded-lg">
|
||
Aucun impact évalué — cliquez sur "Analyser IA" pour lancer l'évaluation.
|
||
</div>
|
||
) : (
|
||
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
|
||
{/* Header */}
|
||
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
|
||
style={{ gridTemplateColumns: '6rem 1.5rem 8rem 2.5rem 1fr 3rem 3rem 1.5rem 1.5rem' }}>
|
||
<span>Instrument</span>
|
||
<span></span>
|
||
<span>Score</span>
|
||
<span>Conf.</span>
|
||
<span>Rationale</span>
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</div>
|
||
{impacts
|
||
.slice()
|
||
.sort((a, b) => (b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score))
|
||
.map(imp => (
|
||
<ImpactRow
|
||
key={imp.id}
|
||
imp={imp}
|
||
eventId={detail.id}
|
||
onUpdated={loadDetail}
|
||
onDeleted={loadDetail}
|
||
/>
|
||
))}
|
||
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
|
||
</div>
|
||
)}
|
||
{impacts.length === 0 && (
|
||
<div className="mt-2">
|
||
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Create event modal ────────────────────────────────────────────────────────
|
||
|
||
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
|
||
const [form, setForm] = useState({
|
||
name: '', start_date: new Date().toISOString().slice(0, 10),
|
||
category: 'fundamental', level: 'short', sub_type: '',
|
||
description: '', impact_score: 0.5,
|
||
})
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const submit = async () => {
|
||
if (!form.name || !form.start_date) return
|
||
setSaving(true)
|
||
await fetch('/api/market-events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...form, source_refs: [] }),
|
||
})
|
||
setSaving(false)
|
||
onCreated()
|
||
onClose()
|
||
}
|
||
|
||
const f = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||
setForm(d => ({ ...d, [k]: e.target.value }))
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
|
||
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-lg shadow-2xl">
|
||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
|
||
<h3 className="text-slate-100 font-semibold">Nouvel événement marché</h3>
|
||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={16} /></button>
|
||
</div>
|
||
<div className="p-5 space-y-3">
|
||
<input value={form.name} onChange={f('name')} placeholder="Nom de l'événement *"
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Date début</div>
|
||
<input type="date" value={form.start_date} onChange={f('start_date')}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Catégorie</div>
|
||
<select value={form.category} onChange={f('category')}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Niveau</div>
|
||
<select value={form.level} onChange={f('level')}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
|
||
<input value={form.sub_type} onChange={f('sub_type')} placeholder="ex: CPI, Conflit..."
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
|
||
</div>
|
||
</div>
|
||
<textarea value={form.description} onChange={f('description')} placeholder="Description..." rows={3}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm resize-none" />
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Score impact: {form.impact_score.toFixed(2)}</div>
|
||
<input type="range" min="0" max="1" step="0.05" value={form.impact_score}
|
||
onChange={e => setForm(d => ({ ...d, impact_score: +e.target.value }))}
|
||
className="w-full accent-amber-400" />
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 px-5 pb-5">
|
||
<button onClick={submit} disabled={saving || !form.name}
|
||
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Créer
|
||
</button>
|
||
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Event list row ────────────────────────────────────────────────────────────
|
||
|
||
function EventRow({
|
||
ev, selected, onClick,
|
||
}: { ev: MarketEvent; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<div
|
||
onClick={onClick}
|
||
className={clsx(
|
||
'flex items-center gap-3 px-4 py-3 cursor-pointer border-b border-slate-800/50 hover:bg-slate-800/40 transition-colors',
|
||
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
|
||
)}
|
||
>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[ev.category] ?? 'bg-slate-500')} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm text-slate-200 truncate font-medium">{ev.name}</div>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className="text-xs text-slate-500">{ev.start_date}</span>
|
||
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
|
||
{ev.origin && (
|
||
<span className="text-xs shrink-0" title={(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).description}>
|
||
{(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).icon}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-xs font-bold" style={{ color: ev.impact_score >= 0.7 ? '#ef4444' : ev.impact_score >= 0.5 ? '#f59e0b' : '#10b981' }}>
|
||
{ev.impact_score.toFixed(2)}
|
||
</div>
|
||
<div className={clsx('text-xs', LEVEL_COLORS[ev.level])}>{ev.level}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Types catégorie ──────────────────────────────────────────────────────────
|
||
|
||
interface DefaultImpact {
|
||
instrument_id: string
|
||
sensitivity: number // 0-1
|
||
typical_direction: string
|
||
notes: string
|
||
}
|
||
|
||
interface EventCategory {
|
||
id?: number
|
||
name: string
|
||
type: string
|
||
sub_type: string
|
||
description: string
|
||
default_impacts: DefaultImpact[]
|
||
is_ai_generated?: number
|
||
updated_at?: string
|
||
}
|
||
|
||
// ── Category editor panel ─────────────────────────────────────────────────────
|
||
|
||
function CategoryEditor({
|
||
cat, onSaved, onDeleted, onClose,
|
||
}: {
|
||
cat: EventCategory | null
|
||
onSaved: () => void
|
||
onDeleted?: () => void
|
||
onClose: () => void
|
||
}) {
|
||
const isNew = !cat?.id
|
||
const [form, setForm] = useState<EventCategory>(cat ?? {
|
||
name: '', type: 'event_calendar', sub_type: '', description: '', default_impacts: [],
|
||
})
|
||
const [saving, setSaving] = useState(false)
|
||
const [deleting, setDel] = useState(false)
|
||
|
||
const save = async () => {
|
||
if (!form.name) return
|
||
setSaving(true)
|
||
await fetch(`/api/impact/categories/${encodeURIComponent(form.name)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: form.type, sub_type: form.sub_type,
|
||
description: form.description,
|
||
default_impacts: form.default_impacts,
|
||
}),
|
||
})
|
||
setSaving(false)
|
||
onSaved()
|
||
onClose()
|
||
}
|
||
|
||
const del = async () => {
|
||
if (!cat?.name || !confirm(`Supprimer la catégorie "${cat.name}" ?`)) return
|
||
setDel(true)
|
||
await fetch(`/api/impact/categories/${encodeURIComponent(cat.name)}`, { method: 'DELETE' })
|
||
setDel(false)
|
||
onDeleted?.()
|
||
onClose()
|
||
}
|
||
|
||
const setImpact = (idx: number, field: keyof DefaultImpact, value: any) => {
|
||
setForm(f => {
|
||
const impacts = [...f.default_impacts]
|
||
impacts[idx] = { ...impacts[idx], [field]: value }
|
||
return { ...f, default_impacts: impacts }
|
||
})
|
||
}
|
||
|
||
const addImpact = () => {
|
||
setForm(f => ({
|
||
...f,
|
||
default_impacts: [...f.default_impacts, { instrument_id: 'SPY', sensitivity: 0.5, typical_direction: 'depends_on_outcome', notes: '' }],
|
||
}))
|
||
}
|
||
|
||
const removeImpact = (idx: number) => {
|
||
setForm(f => ({ ...f, default_impacts: f.default_impacts.filter((_, i) => i !== idx) }))
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full overflow-hidden">
|
||
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
|
||
<h3 className="text-slate-100 font-semibold text-sm flex-1">
|
||
{isNew ? 'Nouvelle catégorie' : `Éditer : ${cat!.name}`}
|
||
</h3>
|
||
{!isNew && (
|
||
<button onClick={del} disabled={deleting}
|
||
className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 border border-red-800/50 rounded px-2 py-1">
|
||
{deleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />} Supprimer
|
||
</button>
|
||
)}
|
||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={14} /></button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||
{/* Name + type */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Nom *</div>
|
||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||
disabled={!isNew}
|
||
placeholder="ex: BOE — Décision de taux"
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm disabled:opacity-50" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Type</div>
|
||
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm">
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
|
||
<input value={form.sub_type} onChange={e => setForm(f => ({ ...f, sub_type: e.target.value }))}
|
||
placeholder="ex: BOE"
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Description</div>
|
||
<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||
placeholder="Résumé court..."
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Default impacts table */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs text-slate-500 uppercase tracking-wide">
|
||
Impacts par défaut
|
||
<span className="text-slate-600 ml-1.5 normal-case">— l'IA s'appuie sur ces sensibilités comme base</span>
|
||
</div>
|
||
<button onClick={addImpact}
|
||
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 border border-emerald-800/50 rounded px-2 py-1">
|
||
<Plus size={11} /> Ajouter
|
||
</button>
|
||
</div>
|
||
|
||
{form.default_impacts.length === 0 ? (
|
||
<div className="text-xs text-slate-600 italic px-3 py-3 bg-slate-800/20 rounded-lg border border-slate-800/40">
|
||
Aucun impact par défaut — cliquez sur "Ajouter" pour en définir.
|
||
</div>
|
||
) : (
|
||
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
|
||
{/* Header */}
|
||
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
|
||
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
|
||
<span>Instrument</span>
|
||
<span>Sensibilité</span>
|
||
<span></span>
|
||
<span>Direction type</span>
|
||
<span>Notes</span>
|
||
<span></span>
|
||
</div>
|
||
{form.default_impacts.map((imp, idx) => (
|
||
<div key={idx} className="grid items-center gap-2 px-3 py-2 border-b border-slate-800/60 last:border-0"
|
||
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
|
||
<select value={imp.instrument_id}
|
||
onChange={e => setImpact(idx, 'instrument_id', e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
|
||
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
<div className="flex flex-col gap-0.5">
|
||
<input type="range" min="0" max="1" step="0.05" value={imp.sensitivity}
|
||
onChange={e => setImpact(idx, 'sensitivity', +e.target.value)}
|
||
className="accent-amber-400 w-full" />
|
||
<span className="text-xs text-center text-amber-300">{imp.sensitivity.toFixed(2)}</span>
|
||
</div>
|
||
<span className={clsx('text-sm font-bold text-center', DIR_COLORS[imp.typical_direction] ?? 'text-slate-400')}>
|
||
{DIR_ICONS[imp.typical_direction] ?? '●'}
|
||
</span>
|
||
<select value={imp.typical_direction}
|
||
onChange={e => setImpact(idx, 'typical_direction', e.target.value)}
|
||
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
|
||
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
|
||
</select>
|
||
<input value={imp.notes} onChange={e => setImpact(idx, 'notes', e.target.value)}
|
||
placeholder="Note..."
|
||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs w-full" />
|
||
<button onClick={() => removeImpact(idx)}
|
||
className="text-slate-600 hover:text-red-400"><Trash2 size={12} /></button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-5 pb-5 pt-3 border-t border-slate-700/30 flex gap-2 shrink-0">
|
||
<button onClick={save} disabled={saving || !form.name}
|
||
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
|
||
{isNew ? 'Créer' : 'Sauvegarder'}
|
||
</button>
|
||
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Categories panel ──────────────────────────────────────────────────────────
|
||
|
||
function CategoriesPanel() {
|
||
const [cats, setCats] = useState<EventCategory[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [selected, setSelected] = useState<EventCategory | null>(null)
|
||
const [showNew, setShowNew] = useState(false)
|
||
const [search, setSearch] = useState('')
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
const r = await fetch('/api/impact/categories')
|
||
if (r.ok) {
|
||
const data = await r.json()
|
||
setCats(data.map((c: any) => ({
|
||
...c,
|
||
default_impacts: typeof c.default_impacts === 'string'
|
||
? JSON.parse(c.default_impacts || '[]')
|
||
: (c.default_impacts || []),
|
||
})))
|
||
}
|
||
setLoading(false)
|
||
}, [])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const filtered = cats.filter(c =>
|
||
!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||
c.type.includes(search.toLowerCase()) || c.sub_type.toLowerCase().includes(search.toLowerCase())
|
||
)
|
||
|
||
// Group by type
|
||
const grouped = CATEGORIES.reduce((acc, t) => {
|
||
acc[t] = filtered.filter(c => c.type === t)
|
||
return acc
|
||
}, {} as Record<string, EventCategory[]>)
|
||
|
||
return (
|
||
<div className="flex h-full overflow-hidden">
|
||
{/* Left: list */}
|
||
<div className="w-72 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-slate-700/30 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-semibold text-slate-200 flex-1">Catégories ({cats.length})</span>
|
||
<button onClick={() => { setSelected(null); setShowNew(true) }}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-2 py-1">
|
||
<Plus size={11} /> Nouvelle
|
||
</button>
|
||
<button onClick={load} disabled={loading}
|
||
className="p-1 text-slate-500 hover:text-slate-300">
|
||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||
</button>
|
||
</div>
|
||
<div className="relative">
|
||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||
placeholder="Filtrer..."
|
||
className="w-full bg-slate-800/60 border border-slate-700/50 rounded pl-7 pr-3 py-1.5 text-xs text-slate-200 placeholder:text-slate-600" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{CATEGORIES.map(type => {
|
||
const group = grouped[type] ?? []
|
||
if (group.length === 0) return null
|
||
return (
|
||
<div key={type}>
|
||
<div className={clsx('px-3 py-1.5 text-xs font-semibold uppercase tracking-wide sticky top-0 bg-dark-900/90',
|
||
CAT_COLORS[type]?.split(' ')[1] ?? 'text-slate-500')}>
|
||
{type.replace('_', ' ')} ({group.length})
|
||
</div>
|
||
{group.map(c => (
|
||
<div key={c.name}
|
||
onClick={() => { setSelected(c); setShowNew(false) }}
|
||
className={clsx(
|
||
'flex items-center gap-2 px-4 py-2.5 cursor-pointer border-b border-slate-800/40 hover:bg-slate-800/40',
|
||
selected?.name === c.name && 'bg-slate-800/60 border-l-2 border-l-blue-500',
|
||
)}>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[c.type] ?? 'bg-slate-500')} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs text-slate-200 font-medium truncate">{c.name}</div>
|
||
<div className="text-xs text-slate-600">{c.default_impacts.length} instruments</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: editor */}
|
||
<div className="flex-1 overflow-hidden">
|
||
{(showNew || selected) ? (
|
||
<CategoryEditor
|
||
key={showNew ? 'new' : selected!.name}
|
||
cat={showNew ? null : selected}
|
||
onSaved={() => { load(); setShowNew(false) }}
|
||
onDeleted={() => { load(); setSelected(null) }}
|
||
onClose={() => { setShowNew(false); setSelected(null) }}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
|
||
<SlidersHorizontal size={36} className="opacity-30" />
|
||
<div className="text-sm">Sélectionne une catégorie pour l'éditer</div>
|
||
<div className="text-xs opacity-60">Les impacts par défaut sont utilisés par l'IA comme base de référence</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function MarketEvents() {
|
||
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
|
||
const [events, setEvents] = useState<MarketEvent[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [selected, setSelected] = useState<MarketEvent | null>(null)
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
const [bulkIds, setBulkIds] = useState<Set<number>>(new Set())
|
||
const [bulking, setBulking] = useState(false)
|
||
const [bulkMsg, setBulkMsg] = useState<string | null>(null)
|
||
|
||
// ── Filter state ─────────────────────────────────────────────────────────
|
||
const [search, setSearch] = useState('')
|
||
const [category, setCategory] = useState('')
|
||
const [level, setLevel] = useState('')
|
||
const [minScore, setMinScore] = useState(0)
|
||
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
|
||
const [dateFrom, setDateFrom] = useState('')
|
||
const [dateTo, setDateTo] = useState('')
|
||
const [datePreset, setDatePreset] = useState<string>('all') // 7d|30d|90d|6m|1y|all|custom
|
||
// Instrument impact filter
|
||
const [instTicker, setInstTicker] = useState('')
|
||
const [instMinScore, setInstMin] = useState(0.3)
|
||
const [instDir, setInstDir] = useState('')
|
||
const [showInstFilter, setShowInst] = useState(false)
|
||
// Sort
|
||
const [sortBy, setSortBy] = useState<'date'|'score'|'name'|'instrument_score'>('date')
|
||
const [sortDir, setSortDir] = useState<'desc'|'asc'>('desc')
|
||
// UI
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
|
||
// ── Date preset helper ────────────────────────────────────────────────────
|
||
const applyPreset = useCallback((preset: string) => {
|
||
setDatePreset(preset)
|
||
if (preset === 'custom' || preset === 'all') {
|
||
setDateFrom(''); setDateTo('')
|
||
return
|
||
}
|
||
const now = new Date()
|
||
const to = now.toISOString().slice(0, 10)
|
||
const from = new Date(now)
|
||
if (preset === '7d') from.setDate(now.getDate() - 7)
|
||
if (preset === '30d') from.setDate(now.getDate() - 30)
|
||
if (preset === '90d') from.setDate(now.getDate() - 90)
|
||
if (preset === '6m') from.setMonth(now.getMonth() - 6)
|
||
if (preset === '1y') from.setFullYear(now.getFullYear() - 1)
|
||
setDateFrom(from.toISOString().slice(0, 10))
|
||
setDateTo(to)
|
||
}, [])
|
||
|
||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
const params = new URLSearchParams()
|
||
if (search) params.set('search', search)
|
||
if (category) params.set('category', category)
|
||
if (level) params.set('level', level)
|
||
if (minScore) params.set('min_score', String(minScore))
|
||
if (dateFrom) params.set('date_from', dateFrom)
|
||
if (dateTo) params.set('date_to', dateTo)
|
||
if (evaluated) params.set('evaluated', evaluated)
|
||
if (instTicker) {
|
||
params.set('instrument', instTicker)
|
||
if (instMinScore > 0) params.set('min_instrument_score', String(instMinScore))
|
||
if (instDir) params.set('instrument_direction', instDir)
|
||
}
|
||
params.set('sort_by', instTicker ? 'instrument_score' : sortBy)
|
||
params.set('sort_dir', sortDir)
|
||
params.set('limit', '500')
|
||
const r = await fetch(`/api/market-events?${params}`)
|
||
if (r.ok) {
|
||
const d = await r.json()
|
||
setEvents(d.events)
|
||
setTotal(d.total)
|
||
}
|
||
setLoading(false)
|
||
}, [search, category, level, minScore, dateFrom, dateTo, evaluated,
|
||
instTicker, instMinScore, instDir, sortBy, sortDir])
|
||
|
||
useEffect(() => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||
debounceRef.current = setTimeout(load, 250)
|
||
}, [load])
|
||
|
||
const bulkEvaluate = async () => {
|
||
if (bulkIds.size === 0) return
|
||
setBulking(true)
|
||
setBulkMsg(null)
|
||
const r = await fetch('/api/market-events/bulk-evaluate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify([...bulkIds]),
|
||
})
|
||
const d = await r.json()
|
||
setBulkMsg(`✓ ${d.evaluated} évalués${d.errors > 0 ? `, ${d.errors} erreurs` : ''}`)
|
||
setBulkIds(new Set())
|
||
setBulking(false)
|
||
load()
|
||
}
|
||
|
||
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
|
||
|
||
return (
|
||
<div className="flex flex-col h-screen overflow-hidden">
|
||
{/* ── Tab bar ── */}
|
||
<div className="flex items-center gap-0 border-b border-slate-700/40 bg-dark-800/80 px-4 shrink-0">
|
||
<h1 className="text-sm font-bold text-slate-300 mr-6 py-3">Market Events</h1>
|
||
{(['events', 'categories'] as const).map(tab => (
|
||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||
className={clsx(
|
||
'px-4 py-3 text-sm border-b-2 transition-colors',
|
||
activeTab === tab
|
||
? 'border-blue-500 text-blue-300 font-medium'
|
||
: 'border-transparent text-slate-500 hover:text-slate-300',
|
||
)}>
|
||
{tab === 'events' ? '📋 Événements' : '🏷️ Catégories & Defaults'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Categories tab ── */}
|
||
{activeTab === 'categories' && (
|
||
<div className="flex-1 overflow-hidden">
|
||
<CategoriesPanel />
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Events tab ── */}
|
||
{activeTab === 'events' && <div className="flex flex-1 overflow-hidden">
|
||
{/* ── Left panel: filters + list ── */}
|
||
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
||
{/* ── Search + actions ── */}
|
||
<div className="px-3 pt-3 pb-2 border-b border-slate-700/30 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||
placeholder="Recherche nom, description..."
|
||
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
|
||
</div>
|
||
<button onClick={() => setShowCreate(true)}
|
||
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2 py-1.5 shrink-0">
|
||
<Plus size={12} />
|
||
</button>
|
||
<button onClick={load} disabled={loading}
|
||
className="p-1.5 text-slate-500 hover:text-slate-300 rounded shrink-0">
|
||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Date presets ── */}
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
<span className="text-xs text-slate-600 mr-1">Période:</span>
|
||
{[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => (
|
||
<button key={v} onClick={() => applyPreset(v)}
|
||
className={clsx('px-2 py-0.5 rounded text-xs border transition-colors',
|
||
datePreset === v
|
||
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
||
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
|
||
{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{datePreset === 'custom' && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Depuis</div>
|
||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Jusqu'à</div>
|
||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Category chips ── */}
|
||
<div className="flex flex-wrap gap-1">
|
||
{CATEGORIES.map(c => (
|
||
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
|
||
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors',
|
||
category === c
|
||
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
|
||
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
|
||
{c.replace('_', ' ')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Sort + more filters toggle ── */}
|
||
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-2">
|
||
<span className="text-xs text-slate-600">Tri:</span>
|
||
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
|
||
className="bg-transparent border-0 text-xs text-slate-400 cursor-pointer focus:outline-none">
|
||
<option value="date">Date</option>
|
||
<option value="score">Score global</option>
|
||
<option value="name">Nom</option>
|
||
</select>
|
||
<button onClick={() => setSortDir(d => d === 'desc' ? 'asc' : 'desc')}
|
||
className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700 rounded px-1.5 py-0.5">
|
||
{sortDir === 'desc' ? '↓' : '↑'}
|
||
</button>
|
||
<div className="flex-1" />
|
||
<button onClick={() => setShowFilters(x => !x)}
|
||
className={clsx('flex items-center gap-1 text-xs border rounded px-2 py-0.5 transition-colors',
|
||
(showFilters || instTicker)
|
||
? 'bg-slate-700 border-slate-600 text-slate-300'
|
||
: 'border-slate-800 text-slate-600 hover:border-slate-600')}>
|
||
<SlidersHorizontal size={10} />
|
||
Filtres {(level || minScore > 0 || evaluated || instTicker) ? '●' : ''}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Extended filters ── */}
|
||
{showFilters && (
|
||
<div className="px-3 py-2 border-b border-slate-800/40 space-y-2 bg-dark-900/50">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Niveau</div>
|
||
<select value={level} onChange={e => setLevel(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Évaluation</div>
|
||
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
<option value="true">Évalués IA</option>
|
||
<option value="false">Non évalués</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Score global min: <span className="text-slate-400">{minScore.toFixed(2)}</span></div>
|
||
<input type="range" min="0" max="1" step="0.05" value={minScore}
|
||
onChange={e => setMinScore(+e.target.value)} className="w-full accent-slate-400" />
|
||
</div>
|
||
|
||
{/* ── Instrument impact filter ── */}
|
||
<div className="border-t border-slate-800/60 pt-2">
|
||
<button onClick={() => setShowInst(x => !x)}
|
||
className={clsx('flex items-center gap-1.5 text-xs font-semibold mb-2 transition-colors',
|
||
instTicker ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
|
||
<BarChart2 size={11} />
|
||
Impact sur instrument {instTicker ? `(${instTicker})` : ''}
|
||
{showInstFilter ? ' ▲' : ' ▼'}
|
||
</button>
|
||
{showInstFilter && (
|
||
<div className="space-y-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Instrument</div>
|
||
<select value={instTicker} onChange={e => { setInstTicker(e.target.value); if (e.target.value) setSortBy('instrument_score') }}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Tous</option>
|
||
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">Direction</div>
|
||
<select value={instDir} onChange={e => setInstDir(e.target.value)}
|
||
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
|
||
<option value="">Toutes</option>
|
||
<option value="bullish">🔼 Bullish</option>
|
||
<option value="bearish">🔽 Bearish</option>
|
||
<option value="neutral">● Neutre</option>
|
||
<option value="depends_on_outcome">◆ Conditionnel</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 mb-0.5">
|
||
Score impact min: <span className="text-amber-300">{instMinScore.toFixed(2)}</span>
|
||
</div>
|
||
<input type="range" min="0" max="1" step="0.05" value={instMinScore}
|
||
onChange={e => setInstMin(+e.target.value)} className="w-full accent-amber-400" />
|
||
</div>
|
||
{instTicker && (
|
||
<div className="text-xs text-amber-300 bg-amber-900/20 rounded px-2 py-1 border border-amber-800/30">
|
||
★ Tri automatique par impact sur {instTicker}
|
||
</div>
|
||
)}
|
||
{instTicker && (
|
||
<button onClick={() => { setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date') }}
|
||
className="text-xs text-slate-500 hover:text-red-400">
|
||
× Effacer filtre instrument
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Bulk evaluate ── */}
|
||
{unevaluatedIds.length > 0 && (
|
||
<div className="px-3 py-1.5 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
|
||
<span className="text-xs text-blue-300 flex-1">{unevaluatedIds.length} non évalués</span>
|
||
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
|
||
<button onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
|
||
disabled={bulking}
|
||
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
|
||
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
|
||
Évaluer tous
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Count bar ── */}
|
||
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
|
||
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
|
||
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
|
||
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || instTicker) && (
|
||
<button onClick={() => {
|
||
setSearch(''); setCategory(''); setLevel(''); setMinScore(0)
|
||
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
|
||
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
|
||
}} className="ml-auto text-xs text-slate-600 hover:text-red-400">× tout effacer</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── List ── */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{loading && events.length === 0 ? (
|
||
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
|
||
<Loader2 size={16} className="animate-spin mr-2" /> Chargement...
|
||
</div>
|
||
) : events.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-600 text-sm">Aucun événement trouvé</div>
|
||
) : (
|
||
events.map(ev => (
|
||
<EventRow
|
||
key={ev.id}
|
||
ev={ev}
|
||
selected={selected?.id === ev.id}
|
||
onClick={() => setSelected(ev)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Right panel: detail ── */}
|
||
<div className="flex-1 overflow-hidden bg-dark-900/40">
|
||
{selected ? (
|
||
<EventDetail
|
||
key={selected.id}
|
||
event={selected}
|
||
onClose={() => setSelected(null)}
|
||
onUpdated={updated => {
|
||
setEvents(evs => evs.map(e => e.id === updated.id ? { ...e, ...updated } : e))
|
||
}}
|
||
onDeleted={id => {
|
||
setEvents(evs => evs.filter(e => e.id !== id))
|
||
setSelected(null)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
|
||
<BarChart2 size={40} className="opacity-30" />
|
||
<div className="text-sm">Sélectionne un événement pour voir le détail</div>
|
||
<div className="text-xs opacity-60">Sources · Impacts par instrument · Analyse IA</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
|
||
</div>}
|
||
</div>
|
||
)
|
||
}
|