feat: Market Events — catégories, cleanup sidebar, rename
- Supprime Timeline & ImpactMonitor du routing (redirects /impact + /timeline → /market-events)
- Renomme 'Instrument Snap.' → 'Instrument Analysis' dans la sidebar
- Onglet 'Catégories & Defaults' dans Market Events: CRUD complet (créer/éditer/supprimer)
chaque catégorie a une table d'impacts par défaut (instrument, sensibilité, direction, notes)
- impact_service: meilleur matching catégorie (sub_type fuzzy + type fallback)
- DB: delete_event_category() + DELETE /api/impact/categories/{name}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,15 @@ def list_categories() -> List[Dict[str, Any]]:
|
||||
return cats
|
||||
|
||||
|
||||
@router.delete("/categories/{name}")
|
||||
def delete_category(name: str) -> Dict[str, Any]:
|
||||
from services.database import delete_event_category
|
||||
ok = delete_event_category(name)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Category '{name}' not found")
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
|
||||
@router.put("/categories/{name}")
|
||||
def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]:
|
||||
from services.database import upsert_event_category
|
||||
|
||||
@@ -4750,6 +4750,16 @@ def upsert_event_category(cat: Dict[str, Any]) -> int:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_event_category(name: str) -> bool:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM event_categories WHERE name=?", (name,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Instrument impacts ────────────────────────────────────────────────────────
|
||||
|
||||
def save_instrument_impacts(impacts: List[Dict[str, Any]]) -> int:
|
||||
|
||||
@@ -121,23 +121,39 @@ def evaluate_event_impacts(
|
||||
if force and existing:
|
||||
delete_impacts_for_source("event", event_id)
|
||||
|
||||
# Get category defaults for context
|
||||
# Get category defaults — try exact name match first, then fuzzy by sub_type/type
|
||||
category_defaults = None
|
||||
cat_name = event.get("sub_type") or event.get("category", "")
|
||||
if cat_name:
|
||||
for possible in [
|
||||
cat_name,
|
||||
f"FOMC — Décision de taux" if "FOMC" in cat_name.upper() else None,
|
||||
f"NFP — Non-Farm Payrolls" if "NFP" in cat_name.upper() else None,
|
||||
]:
|
||||
if possible:
|
||||
cat = get_event_category(possible)
|
||||
if cat:
|
||||
try:
|
||||
category_defaults = json.loads(cat.get("default_impacts") or "[]")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
from services.database import get_all_event_categories
|
||||
all_cats = get_all_event_categories()
|
||||
|
||||
def _find_cat(candidates):
|
||||
for c in candidates:
|
||||
if c is None:
|
||||
continue
|
||||
row = next((x for x in all_cats if x["name"].lower() == c.lower()), None)
|
||||
if row:
|
||||
try:
|
||||
return json.loads(row.get("default_impacts") or "[]")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
sub = (event.get("sub_type") or "").strip()
|
||||
cat = (event.get("category") or "").strip()
|
||||
name = (event.get("name") or "").upper()
|
||||
|
||||
# Priority: exact sub_type name → fuzzy keyword in category name → type match
|
||||
candidates = [sub]
|
||||
for row in all_cats:
|
||||
rname = row["name"].upper()
|
||||
if sub and sub.upper() in rname:
|
||||
candidates.append(row["name"])
|
||||
elif any(k in name for k in ["FOMC", "FED"] if k in rname):
|
||||
candidates.append(row["name"])
|
||||
elif row.get("type") == cat and not sub:
|
||||
candidates.append(row["name"])
|
||||
|
||||
category_defaults = _find_cat(candidates)
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
|
||||
@@ -23,10 +23,8 @@ import VaRAnalysis from './pages/VaRAnalysis'
|
||||
import PositionHistory from './pages/PositionHistory'
|
||||
import InstitutionalReports from './pages/InstitutionalReports'
|
||||
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 CycleActions from './pages/CycleActions'
|
||||
import MarketEvents from './pages/MarketEvents'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
@@ -68,11 +66,12 @@ export default function App() {
|
||||
<Route path="/logs" element={<SystemLogs />} />
|
||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||
<Route path="/timeline" element={<Timeline />} />
|
||||
<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 />} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="/impact" element={<Navigate to="/market-events" replace />} />
|
||||
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
|
||||
<Route path="/market-events" element={<MarketEvents />} />
|
||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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, Crosshair, PlayCircle, Radio
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio
|
||||
} from 'lucide-react'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -27,12 +27,10 @@ const nav = [
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
|
||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' },
|
||||
{ to: '/market-events', icon: Radio, label: 'Market Events' },
|
||||
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
|
||||
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
|
||||
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||
]
|
||||
|
||||
@@ -705,9 +705,326 @@ function EventRow({
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
@@ -773,13 +1090,38 @@ export default function MarketEvents() {
|
||||
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<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-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-base font-bold text-slate-100 flex-1">Market Events</h1>
|
||||
<h1 className="text-base font-bold text-slate-100 flex-1">Événements</h1>
|
||||
<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.5 py-1.5">
|
||||
<Plus size={12} /> Nouveau
|
||||
@@ -929,6 +1271,7 @@ export default function MarketEvents() {
|
||||
</div>
|
||||
|
||||
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user