Files
OpenFin/frontend/src/components/EventManager.tsx
OpenSquared aeb5233deb feat: frise sub-lanes + event manager + MA indicators + absorption
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>
2026-06-24 20:31:57 +02:00

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>
)
}