Files
OpenFin/frontend/src/pages/MarketEvents.tsx
OpenSquared f71e3be3ff feat: market events filters, no-auto-bootstrap, star label deoverlap
- 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>
2026-06-25 21:27:21 +02:00

1404 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ? '-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>
)
}