Frise chronologique:
- Sub-lane stacking (assignSubLanes) — overlapping events se décalent verticalement
- Zone d'overlap semi-transparente sur la période commune entre 2 événements
- Hauteur dynamique selon nb de sub-lanes par niveau
- Événements en cours avec flèche ▶ à droite, gradient de fin
- Tri par start_date pour placement greedy
Event Manager (composant EventManager.tsx):
- Tableau filtrable par niveau (Long/Moyen/Court)
- Edit modal complet : tous les champs + absorption_pct éditable
- Bouton "IA — Enrichir" par événement → POST /api/timeline/events/{id}/ai-enrich
→ GPT-4o-mini suggère absorption_pct + indicateurs pertinents par niveau temporel
- Delete avec confirmation double-clic
- Expand row pour voir description + indicateurs
- Intégré Timeline page via bouton "Gérer événements"
Backend:
- Nouvelles colonnes market_events: absorption_pct + relevant_indicators (ALTER idempotent)
- DELETE /api/timeline/events/{id}
- POST /api/timeline/events/{id}/ai-enrich
Snapshot Externe:
- AbsorptionBar par événement dans cellule Géopolitique
- MA indicators : fetch 200j history, compute MA10/MA20/MA100 per level (short/med/long)
- Affichage prix vs MA + % écart dans CellMarkets
- Si relevant_indicators configurés sur l'event → utilise ces symbols au lieu des défauts
- Calendar : horizons exclusifs (short 0-7j, medium 8-30j, long 31-90j) — bug corrigé
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
import { useState } from 'react'
|
|
import axios from 'axios'
|
|
import clsx from 'clsx'
|
|
import { Plus, Edit2, Trash2, Sparkles, X, Save, ChevronDown, ChevronUp, Check } from 'lucide-react'
|
|
|
|
const api = axios.create({ baseURL: '/api' })
|
|
|
|
interface Indicator { symbol: string; indicator: string; label: string; rationale?: string }
|
|
|
|
interface MarketEvent {
|
|
id: number; name: string; start_date: string; end_date: string | null
|
|
level: string; category: string; description: string; market_impact: string
|
|
affected_assets: string; impact_score: number
|
|
absorption_pct: number | null; relevant_indicators: string
|
|
}
|
|
|
|
interface Props {
|
|
events: MarketEvent[]
|
|
onRefresh: () => void
|
|
}
|
|
|
|
const LEVELS = ['long', 'medium', 'short'] as const
|
|
const LEVEL_LABELS = { long: 'Long Terme', medium: 'Moyen Terme', short: 'Court Terme' }
|
|
const LEVEL_COLORS = {
|
|
long: { badge: 'bg-violet-900/60 text-violet-300 border-violet-700/50', dot: 'bg-violet-500' },
|
|
medium: { badge: 'bg-blue-900/60 text-blue-300 border-blue-700/50', dot: 'bg-blue-500' },
|
|
short: { badge: 'bg-emerald-900/60 text-emerald-300 border-emerald-700/50', dot: 'bg-emerald-500' },
|
|
}
|
|
|
|
const CATEGORIES = ['macro', 'monetary', 'geopolitical', 'market', 'fiscal']
|
|
|
|
const EMPTY_FORM = {
|
|
name: '', start_date: '', end_date: '', level: 'medium', category: 'macro',
|
|
description: '', market_impact: '', affected_assets: '', impact_score: 0.5,
|
|
absorption_pct: '', relevant_indicators: '',
|
|
}
|
|
|
|
type FormState = typeof EMPTY_FORM
|
|
|
|
function AbsorptionBadge({ pct }: { pct: number | null }) {
|
|
if (pct === null || pct === undefined) return <span className="text-xs text-slate-600">—</span>
|
|
const color = pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400'
|
|
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not yet'
|
|
return (
|
|
<span className={clsx('text-xs font-medium', color)}>
|
|
{pct.toFixed(0)}% <span className="text-slate-600">({label})</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function fmtDate(d: string | null) {
|
|
if (!d) return '—'
|
|
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: '2-digit' })
|
|
}
|
|
|
|
function parseIndicators(raw: string | null | undefined): Indicator[] {
|
|
try { return JSON.parse(raw || '[]') } catch { return [] }
|
|
}
|
|
|
|
// ── Edit / Add Modal ───────────────────────────────────────────────────────────
|
|
|
|
function EventModal({
|
|
event,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
event: MarketEvent | null // null = create new
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
}) {
|
|
const [form, setForm] = useState<FormState>(event ? {
|
|
name: event.name,
|
|
start_date: event.start_date,
|
|
end_date: event.end_date ?? '',
|
|
level: event.level,
|
|
category: event.category,
|
|
description: event.description,
|
|
market_impact: event.market_impact,
|
|
affected_assets: (() => {
|
|
try { return JSON.parse(event.affected_assets || '[]').join(', ') } catch { return '' }
|
|
})(),
|
|
impact_score: event.impact_score,
|
|
absorption_pct: event.absorption_pct !== null && event.absorption_pct !== undefined
|
|
? String(event.absorption_pct) : '',
|
|
relevant_indicators: (() => {
|
|
try {
|
|
const inds = JSON.parse(event.relevant_indicators || '[]')
|
|
return inds.map((i: Indicator) => i.label).join(', ')
|
|
} catch { return '' }
|
|
})(),
|
|
} : { ...EMPTY_FORM })
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
const [enriching, setEnriching] = useState(false)
|
|
const [enrichResult, setEnrichResult] = useState<{ absorption_pct: number; absorption_rationale: string; relevant_indicators: Indicator[] } | null>(null)
|
|
|
|
const set = (k: keyof FormState, v: string | number) =>
|
|
setForm(f => ({ ...f, [k]: v }))
|
|
|
|
async function save() {
|
|
setSaving(true)
|
|
try {
|
|
const payload = {
|
|
name: form.name,
|
|
start_date: form.start_date,
|
|
end_date: form.end_date || null,
|
|
level: form.level,
|
|
category: form.category,
|
|
description: form.description,
|
|
market_impact: form.market_impact,
|
|
affected_assets: form.affected_assets.split(',').map(s => s.trim()).filter(Boolean),
|
|
impact_score: Number(form.impact_score),
|
|
absorption_pct: form.absorption_pct ? Number(form.absorption_pct) : null,
|
|
relevant_indicators: enrichResult?.relevant_indicators ?? [],
|
|
}
|
|
if (event) {
|
|
await api.put(`/timeline/events/${event.id}`, payload)
|
|
} else {
|
|
await api.post('/timeline/events', payload)
|
|
}
|
|
onSaved()
|
|
onClose()
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function enrich() {
|
|
if (!event) return
|
|
setEnriching(true)
|
|
try {
|
|
const res = await api.post(`/timeline/events/${event.id}/ai-enrich`)
|
|
const d = res.data
|
|
setEnrichResult(d)
|
|
set('absorption_pct', String(d.absorption_pct ?? ''))
|
|
} finally {
|
|
setEnriching(false)
|
|
}
|
|
}
|
|
|
|
const indicators = enrichResult?.relevant_indicators ?? parseIndicators(event?.relevant_indicators)
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
|
<div
|
|
className="bg-dark-800 border border-slate-700/40 rounded-xl w-[680px] max-h-[90vh] overflow-y-auto shadow-2xl"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/30">
|
|
<div className="text-sm font-semibold text-white">
|
|
{event ? `Éditer — ${event.name}` : 'Nouvel événement'}
|
|
</div>
|
|
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-4">
|
|
{/* Name + Level + Category */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="col-span-3">
|
|
<label className="text-xs text-slate-500 mb-1 block">Nom *</label>
|
|
<input value={form.name} onChange={e => set('name', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Niveau *</label>
|
|
<select value={form.level} onChange={e => set('level', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
|
|
{LEVELS.map(l => <option key={l} value={l}>{LEVEL_LABELS[l]}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Catégorie</label>
|
|
<select value={form.category} onChange={e => set('category', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
|
|
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Impact score (0-1)</label>
|
|
<input type="number" min={0} max={1} step={0.05}
|
|
value={form.impact_score} onChange={e => set('impact_score', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Date début *</label>
|
|
<input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Date fin (vide = en cours)</label>
|
|
<input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description + Market impact */}
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Description</label>
|
|
<textarea value={form.description} onChange={e => set('description', e.target.value)} rows={2}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Impact marché</label>
|
|
<textarea value={form.market_impact} onChange={e => set('market_impact', e.target.value)} rows={2}
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Assets affectés (séparés par virgule)</label>
|
|
<input value={form.affected_assets} onChange={e => set('affected_assets', e.target.value)}
|
|
placeholder="CL, GC, VIX, SPX..."
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
|
|
</div>
|
|
|
|
{/* Absorption */}
|
|
<div className="bg-dark-900/40 rounded-lg p-3 border border-slate-700/20 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-slate-400 font-medium">Absorption du marché (%)</label>
|
|
{event && (
|
|
<button
|
|
onClick={enrich}
|
|
disabled={enriching}
|
|
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded transition-colors disabled:opacity-40"
|
|
>
|
|
<Sparkles className="w-3 h-3" />
|
|
{enriching ? 'Analyse IA...' : 'IA — Enrichir'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<input type="number" min={0} max={100} step={1}
|
|
value={form.absorption_pct} onChange={e => set('absorption_pct', e.target.value)}
|
|
placeholder="0 = non pricé, 100 = fully priced"
|
|
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
|
|
{enrichResult?.absorption_rationale && (
|
|
<p className="text-xs text-slate-500 italic">{enrichResult.absorption_rationale}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Indicators */}
|
|
{indicators.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-xs text-slate-400 font-medium">Indicateurs pertinents suggérés</div>
|
|
{indicators.map((ind, i) => (
|
|
<div key={i} className="flex items-start gap-2 bg-dark-900/30 rounded px-3 py-1.5">
|
|
<div className="flex-1">
|
|
<span className="text-xs text-white font-medium">{ind.label}</span>
|
|
<span className="text-xs text-slate-500 ml-2">({ind.symbol} · {ind.indicator})</span>
|
|
</div>
|
|
{ind.rationale && (
|
|
<span className="text-xs text-slate-600 italic text-right max-w-[200px]">{ind.rationale}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-slate-700/30">
|
|
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-400 hover:text-white">Annuler</button>
|
|
<button
|
|
onClick={save}
|
|
disabled={saving || !form.name || !form.start_date}
|
|
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-blue-700/50 hover:bg-blue-600/60 text-blue-200 border border-blue-600/40 rounded-lg disabled:opacity-40"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Event Row ──────────────────────────────────────────────────────────────────
|
|
|
|
function EventRow({
|
|
ev, onEdit, onDelete,
|
|
}: {
|
|
ev: MarketEvent; onEdit: () => void; onDelete: () => void
|
|
}) {
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [expanded, setExpanded] = useState(false)
|
|
const level = ev.level as keyof typeof LEVEL_COLORS
|
|
const cfg = LEVEL_COLORS[level] ?? LEVEL_COLORS.medium
|
|
const indicators = parseIndicators(ev.relevant_indicators)
|
|
|
|
async function confirmDelete() {
|
|
if (!deleting) { setDeleting(true); return }
|
|
await api.delete(`/timeline/events/${ev.id}`)
|
|
onDelete()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<tr className="border-b border-slate-700/20 hover:bg-dark-700/30 transition-colors">
|
|
<td className="px-3 py-2">
|
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded border font-bold', cfg.badge)}>
|
|
{ev.level[0].toUpperCase()}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="text-sm text-white font-medium">{ev.name}</div>
|
|
<div className="text-xs text-slate-500">{ev.category}</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-xs text-slate-400 tabular-nums">{fmtDate(ev.start_date)}</td>
|
|
<td className="px-3 py-2 text-xs tabular-nums">
|
|
{ev.end_date ? <span className="text-slate-400">{fmtDate(ev.end_date)}</span> : <span className="text-amber-500/70">en cours</span>}
|
|
</td>
|
|
<td className="px-3 py-2"><AbsorptionBadge pct={ev.absorption_pct} /></td>
|
|
<td className="px-3 py-2 text-xs text-slate-500">{indicators.length > 0 ? `${indicators.length} ind.` : '—'}</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setExpanded(e => !e)} className="p-1 text-slate-600 hover:text-slate-300">
|
|
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
|
</button>
|
|
<button onClick={onEdit} className="p-1 text-slate-600 hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
|
|
<button
|
|
onClick={confirmDelete}
|
|
className={clsx('p-1', deleting ? 'text-red-400 animate-pulse' : 'text-slate-600 hover:text-red-400')}
|
|
title={deleting ? 'Cliquer à nouveau pour confirmer' : 'Supprimer'}
|
|
>
|
|
{deleting ? <Check className="w-3.5 h-3.5" /> : <Trash2 className="w-3.5 h-3.5" />}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{expanded && (
|
|
<tr className="border-b border-slate-700/20 bg-dark-900/20">
|
|
<td />
|
|
<td colSpan={6} className="px-3 py-2 space-y-1.5">
|
|
{ev.description && <p className="text-xs text-slate-400">{ev.description}</p>}
|
|
{ev.market_impact && <p className="text-xs text-slate-500 italic">{ev.market_impact}</p>}
|
|
{indicators.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
{indicators.map((ind, i) => (
|
|
<span key={i} className="text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-slate-400">
|
|
{ind.label} <span className="text-slate-600">({ind.indicator})</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
|
|
export default function EventManager({ events, onRefresh }: Props) {
|
|
const [filter, setFilter] = useState<'all' | 'long' | 'medium' | 'short'>('all')
|
|
const [editing, setEditing] = useState<MarketEvent | null | 'new'>('new' as unknown as null)
|
|
const [modalTarget, setModalTarget] = useState<MarketEvent | null | false>(false)
|
|
|
|
const filtered = events.filter(ev => filter === 'all' || ev.level === filter)
|
|
.sort((a, b) => b.start_date.localeCompare(a.start_date))
|
|
|
|
return (
|
|
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/30">
|
|
<span className="text-sm font-semibold text-slate-300">Événements ({events.length})</span>
|
|
|
|
{/* Level filter */}
|
|
<div className="flex items-center gap-1">
|
|
{(['all', 'long', 'medium', 'short'] as const).map(l => (
|
|
<button key={l}
|
|
onClick={() => setFilter(l)}
|
|
className={clsx(
|
|
'px-2 py-1 text-xs rounded transition-colors',
|
|
filter === l ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-300'
|
|
)}
|
|
>
|
|
{l === 'all' ? 'Tous' : LEVEL_LABELS[l]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setModalTarget(null)}
|
|
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-800/40 hover:bg-blue-700/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
Ajouter
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto max-h-72 overflow-y-auto">
|
|
<table className="w-full text-left">
|
|
<thead className="text-xs text-slate-500 uppercase tracking-wider border-b border-slate-700/30 sticky top-0 bg-dark-800">
|
|
<tr>
|
|
<th className="px-3 py-2 w-10">Niv.</th>
|
|
<th className="px-3 py-2">Nom</th>
|
|
<th className="px-3 py-2">Début</th>
|
|
<th className="px-3 py-2">Fin</th>
|
|
<th className="px-3 py-2">Absorption</th>
|
|
<th className="px-3 py-2">Indicateurs</th>
|
|
<th className="px-3 py-2 w-20" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map(ev => (
|
|
<EventRow
|
|
key={ev.id}
|
|
ev={ev}
|
|
onEdit={() => setModalTarget(ev)}
|
|
onDelete={onRefresh}
|
|
/>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<tr><td colSpan={7} className="px-3 py-6 text-center text-xs text-slate-600 italic">Aucun événement</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{modalTarget !== false && (
|
|
<EventModal
|
|
event={modalTarget}
|
|
onClose={() => setModalTarget(false)}
|
|
onSaved={onRefresh}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|