feat: instrument model

This commit is contained in:
OpenSquared
2026-07-02 23:36:06 +02:00
parent e2144d93e9
commit ff6f0b390d
3 changed files with 585 additions and 5 deletions

View File

@@ -6,7 +6,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
RefreshCw, Edit3, X, Trash2, ChevronDown, ChevronUp,
TrendingUp, TrendingDown, Minus, LineChart, Table2, Network,
TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, Activity,
} from 'lucide-react'
import clsx from 'clsx'
import axios from 'axios'
@@ -726,6 +726,202 @@ function TimelineView({ instrument }: { instrument: string }) {
)
}
// ── Gauge Sync Panel ──────────────────────────────────────────────────────────
interface GaugeSuggestion {
node_id: string
label: string
value: number
unit: string
source: string
confidence: 'HIGH' | 'MEDIUM' | 'LOW'
note: string
}
interface GaugeSuggestionsResponse {
instrument: string
gauge_date: string
n_suggestions: number
suggestions: GaugeSuggestion[]
gauges_available: string[]
}
const CONF_META: Record<string, { color: string; label: string }> = {
HIGH: { color: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', label: 'Direct' },
MEDIUM: { color: 'text-amber-400 bg-amber-900/20 border-amber-700/40', label: 'Dérivé' },
LOW: { color: 'text-slate-400 bg-slate-800/40 border-slate-700/30', label: 'Proxy' },
}
function SyncPanel({ instrument, onClose, onApplied }: {
instrument: string; onClose: () => void; onApplied: () => void
}) {
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
useEffect(() => {
setLoading(true)
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions`)
.then(r => {
setData(r.data)
// Pre-select HIGH confidence items
const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id))
setSelected(highs)
})
.catch(() => setData(null))
.finally(() => setLoading(false))
}, [instrument])
function toggleAll(conf: string) {
if (!data) return
const ids = data.suggestions.filter(s => s.confidence === conf).map(s => s.node_id)
const allSelected = ids.every(id => selected.has(id))
setSelected(prev => {
const n = new Set(prev)
ids.forEach(id => allSelected ? n.delete(id) : n.add(id))
return n
})
}
async function apply() {
if (!data) return
setApplying(true)
try {
const overrides = data.suggestions
.filter(s => selected.has(s.node_id))
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto] ${s.note}` }))
await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides })
onApplied()
onClose()
} finally { setApplying(false) }
}
const selectedCount = selected.size
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}>
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[85vh] flex flex-col"
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
<div>
<div className="text-white font-semibold">Sync Marché {instrument}</div>
{data && (
<div className="text-xs text-slate-500 mt-0.5">
Gauges du {data.gauge_date} · {data.gauges_available.length} indicateurs disponibles
</div>
)}
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white">
<X className="w-4 h-4"/>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<div className="text-center py-8 text-slate-500 text-sm">Chargement des suggestions</div>
)}
{!loading && !data && (
<div className="text-center py-8 text-red-400 text-sm">
Aucun snapshot de gauges disponible. Actualiser les données marché d'abord.
</div>
)}
{data && (
<div className="space-y-3">
{/* Confidence group filters */}
<div className="flex gap-2 flex-wrap">
{(['HIGH','MEDIUM','LOW'] as const).map(conf => {
const items = data.suggestions.filter(s => s.confidence === conf)
const meta = CONF_META[conf]
return items.length > 0 ? (
<button key={conf} onClick={() => toggleAll(conf)}
className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium transition-colors', meta.color)}>
<span>{items.every(s => selected.has(s.node_id)) ? '' : ''}</span>
{meta.label} ({items.length})
</button>
) : null
})}
<span className="ml-auto text-xs text-slate-500 self-center">
{selectedCount} sélectionné{selectedCount > 1 ? 's' : ''}
</span>
</div>
{/* Suggestion rows */}
<div className="rounded-lg border border-slate-700/40 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-dark-700/60 border-b border-slate-700/40 text-slate-500">
<th className="px-3 py-2 w-8"/>
<th className="px-3 py-2 text-left">Variable</th>
<th className="px-3 py-2 text-right">Valeur</th>
<th className="px-3 py-2 text-center w-16">Conf.</th>
<th className="px-3 py-2 text-left text-slate-600">Détail</th>
</tr>
</thead>
<tbody>
{data.suggestions.map(s => {
const isSel = selected.has(s.node_id)
const meta = CONF_META[s.confidence]
return (
<tr key={s.node_id}
onClick={() => setSelected(prev => { const n = new Set(prev); isSel ? n.delete(s.node_id) : n.add(s.node_id); return n })}
className={clsx('border-b border-slate-700/20 cursor-pointer transition-colors',
isSel ? 'bg-blue-900/10' : 'hover:bg-dark-700/20')}>
<td className="px-3 py-2 text-center">
<div className={clsx('w-3.5 h-3.5 rounded border mx-auto transition-colors',
isSel ? 'bg-blue-500 border-blue-400' : 'border-slate-600')}>
{isSel && <span className="text-white text-xs flex items-center justify-center h-full leading-none">✓</span>}
</div>
</td>
<td className="px-3 py-2 text-slate-300 font-medium">{s.label}</td>
<td className="px-3 py-2 text-right font-mono text-white">
{s.value > 0 ? '+' : ''}{s.value} <span className="text-slate-500">{s.unit}</span>
</td>
<td className="px-3 py-2 text-center">
<span className={clsx('px-1.5 py-0.5 rounded border text-xs', meta.color)}>
{meta.label}
</span>
</td>
<td className="px-3 py-2 text-slate-500 truncate max-w-[200px]" title={s.note}>
{s.note}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Warning LOW confidence */}
{data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && (
<div className="flex items-start gap-2 px-3 py-2 rounded bg-amber-900/20 border border-amber-700/30 text-xs text-amber-400">
<span className="shrink-0">⚠</span>
Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) — revérifier avec les données réelles.
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-slate-700/40">
<button onClick={apply} disabled={applying || selectedCount === 0 || !data}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 font-medium transition-colors">
{applying ? 'Import' : `Importer ${selectedCount} valeur${selectedCount > 1 ? 's' : ''}`}
</button>
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded-lg px-4 py-2 transition-colors">
Annuler
</button>
</div>
</div>
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
type ViewMode = 'dag' | 'table' | 'timeline'
@@ -737,6 +933,7 @@ export default function InstrumentModels() {
const [loading, setLoading] = useState(false)
const [editNode, setEditNode] = useState<ModelNode | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [showSync, setShowSync] = useState(false)
const load = useCallback(() => {
setLoading(true)
@@ -779,10 +976,16 @@ export default function InstrumentModels() {
Graphes causaux exhaustifs — propagation DAG 3 couches par instrument
</p>
</div>
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
</button>
<div className="flex gap-2">
<button onClick={() => setShowSync(true)}
className="flex items-center gap-2 px-3 py-2 bg-blue-600/20 border border-blue-700/40 rounded-lg text-blue-400 hover:bg-blue-600/30 text-sm transition-colors font-medium">
<Activity className="w-4 h-4"/> Sync Marché
</button>
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
</button>
</div>
</div>
{/* Instrument tabs */}
@@ -832,6 +1035,17 @@ export default function InstrumentModels() {
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
<div className="text-slate-700">{state.at_date}</div>
</div>
{state.nodes.some(n => n.source === 'manual') && (
<button
onClick={async () => {
if (!confirm('Effacer tous les overrides manuels ?')) return
await api.delete(`/instrument-models/${instrument}/overrides`)
setRefreshKey(k => k + 1)
}}
className="text-xs text-red-500/60 hover:text-red-400 transition-colors text-left">
✕ Reset toutes valeurs
</button>
)}
</div>
</div>
</div>
@@ -903,6 +1117,14 @@ export default function InstrumentModels() {
onClose={() => setEditNode(null)} onSaved={onSaved}
/>
)}
{showSync && (
<SyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
/>
)}
</div>
)
}