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
|
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}")
|
@router.put("/categories/{name}")
|
||||||
def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]:
|
def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]:
|
||||||
from services.database import upsert_event_category
|
from services.database import upsert_event_category
|
||||||
|
|||||||
@@ -4750,6 +4750,16 @@ def upsert_event_category(cat: Dict[str, Any]) -> int:
|
|||||||
conn.close()
|
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 ────────────────────────────────────────────────────────
|
# ── Instrument impacts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_instrument_impacts(impacts: List[Dict[str, Any]]) -> int:
|
def save_instrument_impacts(impacts: List[Dict[str, Any]]) -> int:
|
||||||
|
|||||||
@@ -121,23 +121,39 @@ def evaluate_event_impacts(
|
|||||||
if force and existing:
|
if force and existing:
|
||||||
delete_impacts_for_source("event", event_id)
|
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
|
category_defaults = None
|
||||||
cat_name = event.get("sub_type") or event.get("category", "")
|
from services.database import get_all_event_categories
|
||||||
if cat_name:
|
all_cats = get_all_event_categories()
|
||||||
for possible in [
|
|
||||||
cat_name,
|
def _find_cat(candidates):
|
||||||
f"FOMC — Décision de taux" if "FOMC" in cat_name.upper() else None,
|
for c in candidates:
|
||||||
f"NFP — Non-Farm Payrolls" if "NFP" in cat_name.upper() else None,
|
if c is None:
|
||||||
]:
|
continue
|
||||||
if possible:
|
row = next((x for x in all_cats if x["name"].lower() == c.lower()), None)
|
||||||
cat = get_event_category(possible)
|
if row:
|
||||||
if cat:
|
try:
|
||||||
try:
|
return json.loads(row.get("default_impacts") or "[]")
|
||||||
category_defaults = json.loads(cat.get("default_impacts") or "[]")
|
except Exception:
|
||||||
except Exception:
|
pass
|
||||||
pass
|
return None
|
||||||
break
|
|
||||||
|
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()
|
api_key = _get_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ import VaRAnalysis from './pages/VaRAnalysis'
|
|||||||
import PositionHistory from './pages/PositionHistory'
|
import PositionHistory from './pages/PositionHistory'
|
||||||
import InstitutionalReports from './pages/InstitutionalReports'
|
import InstitutionalReports from './pages/InstitutionalReports'
|
||||||
import SpecialistDesks from './pages/SpecialistDesks'
|
import SpecialistDesks from './pages/SpecialistDesks'
|
||||||
import Timeline from './pages/Timeline'
|
|
||||||
import ExternalSnapshot from './pages/ExternalSnapshot'
|
import ExternalSnapshot from './pages/ExternalSnapshot'
|
||||||
import InstrumentDashboard from './pages/InstrumentDashboard'
|
import InstrumentDashboard from './pages/InstrumentDashboard'
|
||||||
import ImpactMonitor from './pages/ImpactMonitor'
|
|
||||||
import CycleActions from './pages/CycleActions'
|
import CycleActions from './pages/CycleActions'
|
||||||
import MarketEvents from './pages/MarketEvents'
|
import MarketEvents from './pages/MarketEvents'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
@@ -68,11 +66,12 @@ export default function App() {
|
|||||||
<Route path="/logs" element={<SystemLogs />} />
|
<Route path="/logs" element={<SystemLogs />} />
|
||||||
<Route path="/institutional" element={<InstitutionalReports />} />
|
<Route path="/institutional" element={<InstitutionalReports />} />
|
||||||
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
<Route path="/specialist-desks" element={<SpecialistDesks />} />
|
||||||
<Route path="/timeline" element={<Timeline />} />
|
|
||||||
<Route path="/snapshot" element={<ExternalSnapshot />} />
|
<Route path="/snapshot" element={<ExternalSnapshot />} />
|
||||||
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
|
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
|
||||||
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
|
<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="/market-events" element={<MarketEvents />} />
|
||||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
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'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -27,12 +27,10 @@ const nav = [
|
|||||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
{ 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: '/market-events', icon: Radio, label: 'Market Events' },
|
||||||
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
|
|
||||||
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
|
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
|
||||||
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
|
||||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
{ 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 ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function MarketEvents() {
|
export default function MarketEvents() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
|
||||||
const [events, setEvents] = useState<MarketEvent[]>([])
|
const [events, setEvents] = useState<MarketEvent[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -773,13 +1090,38 @@ export default function MarketEvents() {
|
|||||||
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
|
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
|
||||||
|
|
||||||
return (
|
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 ── */}
|
{/* ── Left panel: filters + list ── */}
|
||||||
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
|
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
|
||||||
<div className="flex items-center gap-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)}
|
<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">
|
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
|
<Plus size={12} /> Nouveau
|
||||||
@@ -929,6 +1271,7 @@ export default function MarketEvents() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
|
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user