feat: Impact Monitor — AI impact evaluation for eco events & geopolitical news

- New DB tables: event_categories (18 bootstrap categories) + instrument_impacts
- impact_categories_bootstrap.py: FOMC/NFP/CPI/GDP/PCE/ISM/BOJ/ECB/BOE/OPEC+ + 7 géopolitical categories with per-instrument sensitivity & direction defaults
- impact_service.py: GPT-4o-mini evaluation (0-1 score, direction, rationale) + monitor data aggregation
- routers/impact.py: GET/POST endpoints for evaluate/bulk/monitor/adjust/categories
- ImpactMonitor.tsx: two-tab page (Évalués / À évaluer), top instruments bar, inline score override modal, category browser
- Startup auto-bootstrap for impact categories
- Route /impact + sidebar nav entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 16:26:13 +02:00
parent 9ed59c5ca2
commit a68896cf67
8 changed files with 1590 additions and 3 deletions

View File

@@ -26,6 +26,7 @@ import SpecialistDesks from './pages/SpecialistDesks'
import Timeline from './pages/Timeline'
import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import ImpactMonitor from './pages/ImpactMonitor'
import { Navigate } from 'react-router-dom'
import { useCycleWatcher } from './hooks/useApi'
@@ -69,6 +70,7 @@ export default function App() {
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/impact" element={<ImpactMonitor />} />
</Routes>
</main>
</div>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -28,6 +28,7 @@ const nav = [
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/timeline', icon: Layers, label: 'Timeline' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },

View File

@@ -0,0 +1,643 @@
import { useState, useEffect, useCallback } from 'react'
import {
Zap, RefreshCw, ChevronDown, ChevronRight, AlertCircle,
TrendingUp, TrendingDown, Minus, Settings, Play, Check, Sliders,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
interface Impact {
id: number
instrument_id: string
impact_score: number
direction: string
rationale: string
confidence: number
ai_generated: number
manually_adjusted: number
adjusted_score?: number
adjusted_direction?: string
override_rationale?: string
}
interface EvaluatedSource {
source_type: string
source_id: number
source_name: string
source_date: string
category_name: string
max_score: number
n_instruments: number
evaluated_at: string
impacts: Impact[]
}
interface UnevaluatedEvent {
id: number
name: string
start_date: string
category: string
sub_type: string
impact_score: number
}
interface TopInstrument {
instrument_id: string
avg_score: number
n_events: number
}
interface MonitorData {
evaluated: EvaluatedSource[]
unevaluated: UnevaluatedEvent[]
top_instruments: TopInstrument[]
period_days: number
min_score: number
}
interface DefaultImpact {
instrument_id: string
sensitivity: number
typical_direction: string
notes: string
}
interface Category {
id: number
name: string
type: string
sub_type: string
description: string
default_impacts: DefaultImpact[]
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const DIR_CFG: Record<string, { icon: React.ReactNode; color: string; short: string }> = {
bullish: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-400', short: '↑' },
bearish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-400', short: '↓' },
neutral: { icon: <Minus className="w-3 h-3" />, color: 'text-slate-400', short: '—' },
depends_on_outcome: { icon: <Minus className="w-3 h-3" />, color: 'text-amber-400', short: '?' },
bullish_if_beat: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-300', short: '↑?' },
bearish_if_hawkish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-300', short: '↓H' },
bullish_if_surprise: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-violet-400', short: '↑!' },
}
const TYPE_CFG: Record<string, { color: string; label: string }> = {
event_calendar: { color: 'text-amber-400 bg-amber-900/30 border-amber-700/30', label: 'Calendrier' },
geopolitical: { color: 'text-red-400 bg-red-900/30 border-red-700/30', label: 'Géopolitique' },
}
function scoreBar(score: number, color = 'bg-blue-500') {
return (
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${score * 100}%` }} />
</div>
<span className="text-xs tabular-nums text-slate-400">{(score * 100).toFixed(0)}</span>
</div>
)
}
function scoreColor(score: number) {
if (score >= 0.7) return 'bg-red-500'
if (score >= 0.5) return 'bg-orange-500'
if (score >= 0.3) return 'bg-amber-500'
return 'bg-slate-500'
}
function dirColor(dir: string) {
return DIR_CFG[dir]?.color ?? 'text-slate-400'
}
// ── AdjustModal ───────────────────────────────────────────────────────────────
function AdjustModal({
impact, onClose, onSave,
}: {
impact: Impact
onClose: () => void
onSave: (id: number, score: number, dir: string, rationale: string) => void
}) {
const [score, setScore] = useState(impact.adjusted_score ?? impact.impact_score)
const [dir, setDir] = useState(impact.adjusted_direction ?? impact.direction)
const [rationale, setRationale] = useState(impact.override_rationale ?? '')
const DIRS = ['bullish','bearish','neutral','depends_on_outcome']
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-5 w-[400px] space-y-4 shadow-2xl">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Ajuster l'impact — {impact.instrument_id}</span>
<button onClick={onClose} className="text-slate-500 hover:text-white"><span className="text-lg">×</span></button>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Score IA : {(impact.impact_score * 100).toFixed(0)}</div>
<label className="text-xs text-slate-400 mb-1 block">Score ajusté : {(score * 100).toFixed(0)}</label>
<input type="range" min={0} max={1} step={0.05} value={score}
onChange={e => setScore(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Direction</label>
<div className="flex gap-1.5">
{DIRS.map(d => (
<button key={d} onClick={() => setDir(d)}
className={clsx('flex-1 text-xs py-1 rounded border transition-colors',
dir === d
? 'border-blue-500 bg-blue-900/30 text-blue-300'
: 'border-slate-700/40 text-slate-500 hover:text-white'
)}>
{d === 'depends_on_outcome' ? '?' : d.slice(0,4)}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Commentaire</label>
<textarea
className="w-full text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-1.5 text-slate-300 placeholder-slate-600 resize-none"
rows={2} placeholder="Pourquoi ajuster..."
value={rationale} onChange={e => setRationale(e.target.value)}
/>
</div>
<div className="flex gap-2">
<button onClick={onClose}
className="flex-1 text-xs py-1.5 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white">
Annuler
</button>
<button onClick={() => { onSave(impact.id, score, dir, rationale); onClose() }}
className="flex-1 text-xs py-1.5 bg-blue-700/40 hover:bg-blue-700/60 text-blue-300 border border-blue-600/40 rounded-lg">
Sauvegarder
</button>
</div>
</div>
</div>
)
}
// ── ImpactRow ─────────────────────────────────────────────────────────────────
function ImpactRow({
impact, onAdjust,
}: {
impact: Impact
onAdjust: (imp: Impact) => void
}) {
const score = impact.adjusted_score ?? impact.impact_score
const dir = impact.adjusted_direction ?? impact.direction
const dc = DIR_CFG[dir] ?? DIR_CFG['neutral']
return (
<div className="flex items-center gap-2 py-0.5 group">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{impact.instrument_id}</span>
{scoreBar(score, scoreColor(score))}
<span className={clsx('text-xs flex items-center gap-0.5 w-16 shrink-0', dc.color)}>
{dc.icon}{dc.short}
</span>
<span className="text-xs text-slate-600 truncate flex-1">{impact.rationale}</span>
{impact.manually_adjusted === 1 && (
<span className="text-xs text-violet-500 shrink-0">✎</span>
)}
<button
onClick={() => onAdjust(impact)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-600 hover:text-white transition-opacity"
title="Ajuster">
<Sliders className="w-3 h-3" />
</button>
</div>
)
}
// ── EvaluatedCard ─────────────────────────────────────────────────────────────
function EvaluatedCard({
source, onAdjust, onReEval,
}: {
source: EvaluatedSource
onAdjust: (imp: Impact) => void
onReEval: (id: number) => void
}) {
const [open, setOpen] = useState(false)
const catType = source.impacts[0] ? 'event_calendar' : 'geopolitical'
const tcfg = TYPE_CFG[source.source_type === 'news' ? 'geopolitical' : 'event_calendar']
const sorted = [...source.impacts].sort((a, b) =>
(b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score)
)
return (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 overflow-hidden">
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-dark-700/40 transition-colors"
onClick={() => setOpen(o => !o)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-white truncate">{source.source_name}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>{source.source_date}</span>
{source.category_name && (
<span className={clsx('px-1.5 py-0 rounded border', tcfg?.color ?? 'text-slate-400 border-slate-700/30')}
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{source.category_name}
</span>
)}
<span>{source.n_instruments} instruments</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* Top 3 instruments mini-badges */}
<div className="flex gap-1">
{sorted.slice(0, 3).map(imp => (
<span key={imp.instrument_id}
className={clsx('text-xs px-1.5 py-0.5 rounded font-mono',
(imp.adjusted_score ?? imp.impact_score) >= 0.7
? 'bg-red-900/30 text-red-300'
: (imp.adjusted_score ?? imp.impact_score) >= 0.5
? 'bg-orange-900/30 text-orange-300'
: 'bg-slate-700/40 text-slate-400'
)}>
{imp.instrument_id}
</span>
))}
</div>
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(source.max_score))}
style={{ width: `${source.max_score * 100}%` }} />
</div>
<span className="text-xs text-slate-400">{(source.max_score * 100).toFixed(0)}</span>
</div>
<button
onClick={e => { e.stopPropagation(); onReEval(source.source_id) }}
className="p-1 text-slate-600 hover:text-amber-400 transition-colors"
title="Re-évaluer">
<RefreshCw className="w-3 h-3" />
</button>
{open ? <ChevronDown className="w-4 h-4 text-slate-500" /> : <ChevronRight className="w-4 h-4 text-slate-500" />}
</div>
</div>
{open && (
<div className="px-4 pb-3 border-t border-slate-700/20 pt-2 space-y-0.5">
{sorted.map(imp => (
<ImpactRow key={imp.id} impact={imp} onAdjust={onAdjust} />
))}
</div>
)}
</div>
)
}
// ── CategoryPanel ─────────────────────────────────────────────────────────────
function CategoryPanel({ onClose }: { onClose: () => void }) {
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<Category | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/impact/categories').then(r => {
setCategories(r.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/80 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Catégories & Impacts par défaut</span>
<button onClick={onClose} className="text-slate-500 hover:text-white text-lg">×</button>
</div>
{loading ? (
<div className="text-xs text-slate-500">Chargement...</div>
) : (
<div className="grid grid-cols-2 gap-3">
{/* Category list */}
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
{categories.map(cat => (
<div key={cat.name}
className={clsx('rounded-lg border px-3 py-2 cursor-pointer transition-colors',
selected?.name === cat.name
? 'border-blue-600/50 bg-blue-900/20'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
)}
onClick={() => setSelected(cat)}
>
<div className="flex items-center gap-2">
<span className={clsx('text-xs px-1.5 py-0 rounded border',
TYPE_CFG[cat.type]?.color ?? 'text-slate-400 border-slate-700/30'
)} style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{TYPE_CFG[cat.type]?.label ?? cat.type}
</span>
<span className="text-xs text-slate-300 truncate">{cat.name}</span>
</div>
<div className="text-xs text-slate-600 mt-0.5">{cat.default_impacts.length} instruments</div>
</div>
))}
</div>
{/* Default impacts detail */}
<div className="border border-slate-700/30 rounded-lg p-3 max-h-[500px] overflow-y-auto">
{selected ? (
<div className="space-y-2">
<div className="text-xs font-semibold text-white">{selected.name}</div>
<div className="text-xs text-slate-500">{selected.description}</div>
<div className="space-y-1 mt-2">
{[...selected.default_impacts]
.sort((a, b) => b.sensitivity - a.sensitivity)
.map(di => {
const dc = DIR_CFG[di.typical_direction] ?? DIR_CFG['neutral']
return (
<div key={di.instrument_id} className="flex items-center gap-2 py-0.5">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{di.instrument_id}</span>
{scoreBar(di.sensitivity, scoreColor(di.sensitivity))}
<span className={clsx('text-xs shrink-0', dc.color)}>{dc.short}</span>
<span className="text-xs text-slate-600 truncate">{di.notes}</span>
</div>
)
})}
</div>
</div>
) : (
<div className="text-xs text-slate-600 italic">Sélectionne une catégorie</div>
)}
</div>
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function ImpactMonitor() {
const [data, setData] = useState<MonitorData | null>(null)
const [loading, setLoading] = useState(false)
const [evaluating, setEvaluating] = useState<Set<number>>(new Set())
const [days, setDays] = useState(14)
const [minScore, setMinScore] = useState(0.3)
const [adjusting, setAdjusting] = useState<Impact | null>(null)
const [showCategories, setShowCategories] = useState(false)
const [tab, setTab] = useState<'evaluated' | 'pending'>('evaluated')
const load = useCallback(() => {
setLoading(true)
api.get(`/impact/monitor?days=${days}&min_score=${minScore}`)
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [days, minScore])
useEffect(() => { load() }, [load])
async function evaluateEvent(eventId: number, force = false) {
setEvaluating(s => new Set(s).add(eventId))
try {
await api.post(`/impact/evaluate/event/${eventId}?force=${force}`)
load()
} catch (e) {
console.error(e)
} finally {
setEvaluating(s => { const n = new Set(s); n.delete(eventId); return n })
}
}
async function evaluateAllPending() {
if (!data?.unevaluated.length) return
const ids = data.unevaluated.slice(0, 10).map(e => e.id)
setEvaluating(new Set(ids))
try {
await api.post('/impact/evaluate/bulk', ids)
load()
} finally {
setEvaluating(new Set())
}
}
async function saveAdjustment(id: number, score: number, dir: string, rationale: string) {
await api.put(`/impact/adjust/${id}`, {
adjusted_score: score,
adjusted_direction: dir,
override_rationale: rationale,
})
load()
}
const evaluated = data?.evaluated ?? []
const unevaluated = data?.unevaluated ?? []
const topInstr = data?.top_instruments ?? []
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-400" />
<h1 className="text-xl font-bold text-white">Impact Monitor</h1>
<span className="text-xs text-slate-500 bg-dark-700 border border-slate-700/40 px-2 py-0.5 rounded-full">
Événements Éco + Géopolitique
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{/* Days selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
days === d ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
)}>
{d}j
</button>
))}
</div>
{/* Min score */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span>Seuil</span>
<select
className="bg-dark-700 border border-slate-700/40 rounded px-1.5 py-1 text-xs text-slate-300"
value={minScore}
onChange={e => setMinScore(parseFloat(e.target.value))}
>
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7].map(v => (
<option key={v} value={v}>{(v * 100).toFixed(0)}</option>
))}
</select>
</div>
<button onClick={load} disabled={loading}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-dark-800 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-3 h-3', loading && 'animate-spin')} />
Actualiser
</button>
<button onClick={() => setShowCategories(s => !s)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
showCategories
? 'bg-slate-700/50 text-white border-slate-600/50'
: 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-white'
)}>
<Settings className="w-3 h-3" />
Catégories
</button>
</div>
</div>
{/* Categories panel */}
{showCategories && <CategoryPanel onClose={() => setShowCategories(false)} />}
{/* Top instruments bar */}
{topInstr.length > 0 && (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">
Instruments les plus impactés — {days}j
</div>
<div className="flex flex-wrap gap-2">
{topInstr.map(ti => (
<div key={ti.instrument_id}
className="flex items-center gap-1.5 bg-dark-700/60 border border-slate-700/30 rounded-lg px-2.5 py-1">
<span className="text-xs font-mono font-semibold text-white">{ti.instrument_id}</span>
<div className="w-12 h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(ti.avg_score))}
style={{ width: `${ti.avg_score * 100}%` }} />
</div>
<span className="text-xs text-slate-500">{ti.n_events}×</span>
</div>
))}
</div>
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-slate-700/30">
<button onClick={() => setTab('evaluated')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'evaluated'
? 'text-white border-blue-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
Évalués <span className="ml-1.5 text-xs text-slate-600">({evaluated.length})</span>
</button>
<button onClick={() => setTab('pending')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'pending'
? 'text-white border-amber-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
À évaluer
{unevaluated.length > 0 && (
<span className="ml-1.5 text-xs px-1.5 py-0.5 bg-amber-900/40 text-amber-400 rounded-full border border-amber-700/30">
{unevaluated.length}
</span>
)}
</button>
</div>
{/* Loading */}
{loading && (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="h-16 rounded-xl bg-dark-800/60 border border-slate-700/30 animate-pulse" />
))}
</div>
)}
{/* Evaluated tab */}
{!loading && tab === 'evaluated' && (
<div className="space-y-2">
{evaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<AlertCircle className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucun événement évalué sur les {days} derniers jours</p>
<p className="text-xs mt-1">Passe à l'onglet "À évaluer" pour lancer les évaluations IA</p>
</div>
) : (
evaluated.map(src => (
<EvaluatedCard
key={`${src.source_type}-${src.source_id}`}
source={src}
onAdjust={imp => setAdjusting(imp)}
onReEval={id => evaluateEvent(id, true)}
/>
))
)}
</div>
)}
{/* Pending tab */}
{!loading && tab === 'pending' && (
<div className="space-y-3">
{unevaluated.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{unevaluated.length} événement(s) récents sans évaluation IA
</span>
<button
onClick={evaluateAllPending}
disabled={evaluating.size > 0}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-amber-800/30 hover:bg-amber-800/50 text-amber-300 border border-amber-700/40 rounded-lg transition-colors disabled:opacity-40">
<Play className="w-3 h-3" />
Évaluer les 10 premiers (IA)
</button>
</div>
)}
{unevaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<Check className="w-8 h-8 mx-auto mb-3 opacity-30 text-emerald-400" />
<p>Tous les événements récents ont é évalués</p>
</div>
) : (
<div className="space-y-2">
{unevaluated.map(ev => (
<div key={ev.id}
className="flex items-center gap-3 rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-300 truncate">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-slate-500">
<span>{ev.start_date}</span>
{ev.sub_type && (
<span className="px-1.5 py-0 rounded bg-slate-700/40 border border-slate-600/30"
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{ev.sub_type}
</span>
)}
</div>
</div>
<button
onClick={() => evaluateEvent(ev.id)}
disabled={evaluating.has(ev.id)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors disabled:opacity-40">
{evaluating.has(ev.id)
? <><RefreshCw className="w-3 h-3 animate-spin" /> Analyse IA...</>
: <><Zap className="w-3 h-3" /> Évaluer IA</>
}
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Adjust modal */}
{adjusting && (
<AdjustModal
impact={adjusting}
onClose={() => setAdjusting(null)}
onSave={saveAdjustment}
/>
)}
</div>
)
}