- 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>
644 lines
26 KiB
TypeScript
644 lines
26 KiB
TypeScript
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 été é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>
|
||
)
|
||
}
|