feat: instrument model
This commit is contained in:
@@ -68,6 +68,7 @@ interface ModelState {
|
||||
nodes: ModelNode[]
|
||||
output_node: string
|
||||
regime: RegimeInfo
|
||||
event_details: Record<string, EventDetail[]>
|
||||
}
|
||||
|
||||
interface TimelinePoint {
|
||||
@@ -98,6 +99,42 @@ interface VirtualEventForm {
|
||||
decay_type: string
|
||||
}
|
||||
|
||||
interface EventDetail {
|
||||
analysis_id: number
|
||||
event_id: number
|
||||
title: string
|
||||
start_date: string
|
||||
days_since: number
|
||||
pip_prediction: number
|
||||
lifecycle_factor: number
|
||||
remaining_pips: number
|
||||
calibration: {
|
||||
absorption_days: number
|
||||
rise_days: number
|
||||
plateau_days: number
|
||||
decay_type: string
|
||||
}
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
analysis_id: number
|
||||
event_id: number
|
||||
title: string
|
||||
start_date: string
|
||||
category: string
|
||||
is_future: boolean
|
||||
days_offset: number
|
||||
pip_prediction: number
|
||||
lifecycle_factor: number
|
||||
remaining_pips: number
|
||||
calibration: {
|
||||
absorption_days: number
|
||||
rise_days: number
|
||||
plateau_days: number
|
||||
decay_type: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
|
||||
@@ -361,7 +398,10 @@ function NodeEditModal({ node, instrument, onClose, onSaved }: {
|
||||
|
||||
const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
|
||||
|
||||
function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => void }) {
|
||||
function NodeCard({ node, onEdit, eventDetails }: {
|
||||
node: ModelNode; onEdit: (n: ModelNode) => void
|
||||
eventDetails?: Record<string, EventDetail[]>
|
||||
}) {
|
||||
const meta = NODE_TYPE_META[node.node_type]
|
||||
const v = node.pip_contribution
|
||||
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
|
||||
@@ -403,7 +443,30 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : node.node_type === 'input_event' ? (() => {
|
||||
const cat = node.event_category || ''
|
||||
const evList = eventDetails?.[cat] || []
|
||||
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
|
||||
const barW = Math.round(maxLf * 100)
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
|
||||
{evList.length > 0 && (
|
||||
<span className="text-xs text-slate-600">{evList.length} ev</span>
|
||||
)}
|
||||
</div>
|
||||
{evList.length > 0 && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width:`${barW}%`}}/>
|
||||
</div>
|
||||
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})() : (
|
||||
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
|
||||
{fmt(v)} pips
|
||||
</div>
|
||||
@@ -435,8 +498,9 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
|
||||
)
|
||||
}
|
||||
|
||||
function DagView({ nodes, instrument, onEdit }: {
|
||||
function DagView({ nodes, instrument, onEdit, eventDetails }: {
|
||||
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
|
||||
eventDetails: Record<string, EventDetail[]>
|
||||
}) {
|
||||
const cols: ModelNode[][] = [[], [], [], []]
|
||||
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
|
||||
@@ -450,7 +514,7 @@ function DagView({ nodes, instrument, onEdit }: {
|
||||
{lbl}
|
||||
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
|
||||
</div>
|
||||
{cols[ci].map(n => <NodeCard key={n.id} node={n} onEdit={onEdit}/>)}
|
||||
{cols[ci].map(n => <NodeCard key={n.id} node={n} onEdit={onEdit} eventDetails={eventDetails}/>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -677,6 +741,7 @@ function TimelineView({ instrument }: { instrument: string }) {
|
||||
const [virtuals, setVirtuals] = useState<VirtualEventForm[]>([])
|
||||
const [whatifLoading, setWhatifLoading] = useState(false)
|
||||
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
|
||||
const [importingCal, setImportingCal] = useState(false)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const activeData = whatifData ?? data
|
||||
@@ -986,12 +1051,42 @@ function TimelineView({ instrument }: { instrument: string }) {
|
||||
{/* Virtual Events Panel */}
|
||||
{showVirtual && (
|
||||
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
|
||||
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
|
||||
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
|
||||
<Plus className="w-3 h-3"/> Ajouter
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
disabled={importingCal}
|
||||
onClick={async () => {
|
||||
setImportingCal(true)
|
||||
try {
|
||||
const r = await api.get<CalendarEvent[]>(
|
||||
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
|
||||
)
|
||||
const imported: VirtualEventForm[] = r.data.map(ev => ({
|
||||
id: ev.analysis_id.toString(),
|
||||
date: ev.start_date,
|
||||
category: ev.category,
|
||||
pips: ev.pip_prediction,
|
||||
label: ev.title || ev.category,
|
||||
absorption_days: ev.calibration.absorption_days,
|
||||
rise_days: ev.calibration.rise_days,
|
||||
plateau_days: ev.calibration.plateau_days,
|
||||
decay_type: ev.calibration.decay_type,
|
||||
}))
|
||||
setVirtuals(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id))
|
||||
return [...prev, ...imported.filter(x => !existingIds.has(x.id))]
|
||||
})
|
||||
} finally { setImportingCal(false) }
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 border border-sky-700/40 rounded px-2 py-0.5 transition-colors disabled:opacity-40">
|
||||
<Activity className="w-3 h-3"/> {importingCal ? '…' : 'Import calendrier'}
|
||||
</button>
|
||||
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
|
||||
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
|
||||
<Plus className="w-3 h-3"/> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{virtuals.length === 0 && (
|
||||
<div className="text-xs text-slate-500 text-center py-2">
|
||||
@@ -1085,25 +1180,28 @@ const CONF_META: Record<string, { color: string; label: string }> = {
|
||||
}
|
||||
|
||||
function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
instrument: string; onClose: () => void; onApplied: () => void
|
||||
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
|
||||
}) {
|
||||
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
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)
|
||||
const [atDate, setAtDate] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
function fetchSuggestions(date?: string) {
|
||||
setLoading(true)
|
||||
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions`)
|
||||
const qs = date ? `?at_date=${date}` : ''
|
||||
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions${qs}`)
|
||||
.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])
|
||||
}
|
||||
|
||||
useEffect(() => { fetchSuggestions() }, [instrument])
|
||||
|
||||
function toggleAll(conf: string) {
|
||||
if (!data) return
|
||||
@@ -1122,9 +1220,9 @@ function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
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}` }))
|
||||
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto${atDate ? ' @'+atDate : ''}] ${s.note}` }))
|
||||
await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides })
|
||||
onApplied()
|
||||
onApplied(atDate || undefined)
|
||||
onClose()
|
||||
} finally { setApplying(false) }
|
||||
}
|
||||
@@ -1139,15 +1237,39 @@ function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<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
|
||||
Snapshot: {data.gauge_date}
|
||||
{(data as any).requested_date && (data as any).requested_date !== data.gauge_date && (
|
||||
<span className="text-amber-500/70 ml-1">(demandé: {(data as any).requested_date})</span>
|
||||
)}
|
||||
· {data.gauges_available.length} indicateurs
|
||||
</div>
|
||||
)}
|
||||
{/* Date picker to initialize at a historical date */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-slate-500">Date d'init :</label>
|
||||
<input
|
||||
type="date"
|
||||
value={atDate}
|
||||
onChange={e => setAtDate(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fetchSuggestions(atDate || undefined)}
|
||||
disabled={loading}
|
||||
className="px-2 py-0.5 bg-blue-600/20 border border-blue-700/40 rounded text-xs text-blue-400 hover:bg-blue-600/30 transition-colors disabled:opacity-40">
|
||||
{loading ? '…' : 'Charger'}
|
||||
</button>
|
||||
{atDate && (
|
||||
<button onClick={() => { setAtDate(''); fetchSuggestions() }}
|
||||
className="text-xs text-slate-600 hover:text-slate-400">✕ Réinitialiser</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-white">
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-white ml-4">
|
||||
<X className="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1254,9 +1376,153 @@ function SyncPanel({ instrument, onClose, onApplied }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Calibration View ──────────────────────────────────────────────────────────
|
||||
|
||||
const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const
|
||||
|
||||
function CalibrationView({ instrument, eventDetails }: {
|
||||
instrument: string
|
||||
eventDetails: Record<string, EventDetail[]>
|
||||
}) {
|
||||
const [localCalib, setLocalCalib] = useState<Record<number, EventDetail['calibration']>>({})
|
||||
const [saving, setSaving] = useState<Record<number, boolean>>({})
|
||||
const [saved, setSaved] = useState<Record<number, boolean>>({})
|
||||
|
||||
const allEvents = Object.values(eventDetails).flat()
|
||||
.sort((a, b) => b.lifecycle_factor - a.lifecycle_factor)
|
||||
|
||||
if (allEvents.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-500 text-sm">
|
||||
Aucun event actif actuellement — les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCalib(ev: EventDetail) {
|
||||
return localCalib[ev.analysis_id] ?? ev.calibration
|
||||
}
|
||||
|
||||
function setField(id: number, field: keyof EventDetail['calibration'], val: string | number) {
|
||||
setLocalCalib(prev => ({
|
||||
...prev,
|
||||
[id]: { ...(prev[id] ?? allEvents.find(e => e.analysis_id === id)!.calibration), [field]: val },
|
||||
}))
|
||||
setSaved(prev => ({ ...prev, [id]: false }))
|
||||
}
|
||||
|
||||
async function saveCalib(ev: EventDetail) {
|
||||
const calib = getCalib(ev)
|
||||
setSaving(prev => ({ ...prev, [ev.analysis_id]: true }))
|
||||
try {
|
||||
await api.put(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`, calib)
|
||||
setSaved(prev => ({ ...prev, [ev.analysis_id]: true }))
|
||||
} finally {
|
||||
setSaving(prev => ({ ...prev, [ev.analysis_id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCalib(ev: EventDetail) {
|
||||
await api.delete(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`)
|
||||
setLocalCalib(prev => { const n = {...prev}; delete n[ev.analysis_id]; return n })
|
||||
setSaved(prev => ({ ...prev, [ev.analysis_id]: false }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-slate-500 mb-3">
|
||||
Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template.
|
||||
</div>
|
||||
{allEvents.map(ev => {
|
||||
const cal = getCalib(ev)
|
||||
const lf = ev.lifecycle_factor
|
||||
const isSaving = saving[ev.analysis_id]
|
||||
const isDirty = !!localCalib[ev.analysis_id]
|
||||
const wasSaved = saved[ev.analysis_id]
|
||||
return (
|
||||
<div key={ev.analysis_id}
|
||||
className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 space-y-2">
|
||||
{/* Event header */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-slate-500 shrink-0">{ev.start_date}</span>
|
||||
<span className="text-xs font-medium text-slate-300 truncate">{ev.title || ev.analysis_id}</span>
|
||||
<span className="text-xs text-slate-600 bg-slate-800 rounded px-1.5 py-0.5 shrink-0">{ev.days_since}j</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className={clsx('text-xs font-bold font-mono', pipColor(ev.pip_prediction))}>
|
||||
{fmt(ev.pip_prediction)}p
|
||||
</span>
|
||||
<span className={clsx('text-xs font-mono', pipColor(ev.remaining_pips))}>
|
||||
→{fmt(ev.remaining_pips)}p
|
||||
</span>
|
||||
{/* Lifecycle bar */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-20 h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full', lf > 0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')}
|
||||
style={{width:`${Math.round(lf*100)}%`}}/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">{Math.round(lf*100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calibration inputs */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
|
||||
<input type="number" min={1} max={365} value={cal.absorption_days}
|
||||
onChange={e => setField(ev.analysis_id, 'absorption_days', parseInt(e.target.value)||1)}
|
||||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-0.5">Rise (j)</label>
|
||||
<input type="number" min={0} max={30} value={cal.rise_days}
|
||||
onChange={e => setField(ev.analysis_id, 'rise_days', parseInt(e.target.value)||0)}
|
||||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-0.5">Plateau (j)</label>
|
||||
<input type="number" min={0} max={30} value={cal.plateau_days}
|
||||
onChange={e => setField(ev.analysis_id, 'plateau_days', parseInt(e.target.value)||0)}
|
||||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-0.5">Decay type</label>
|
||||
<select value={cal.decay_type}
|
||||
onChange={e => setField(ev.analysis_id, 'decay_type', e.target.value)}
|
||||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
|
||||
{DECAY_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{isDirty && !wasSaved && (
|
||||
<button onClick={() => resetCalib(ev)}
|
||||
className="text-xs text-slate-600 hover:text-slate-400 transition-colors">
|
||||
↩ Réinitialiser template
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => saveCalib(ev)} disabled={isSaving}
|
||||
className={clsx('flex items-center gap-1 px-3 py-1 rounded text-xs font-medium transition-colors',
|
||||
wasSaved ? 'bg-emerald-700/40 text-emerald-400 border border-emerald-700/40'
|
||||
: isDirty ? 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
: 'bg-dark-700 text-slate-500 border border-slate-700/30')}>
|
||||
{isSaving ? 'Sauvegarde…' : wasSaved ? '✓ Sauvegardé' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'dag' | 'table' | 'timeline'
|
||||
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration'
|
||||
|
||||
export default function InstrumentModels() {
|
||||
const [instrument, setInstrument] = useState('EURUSD')
|
||||
@@ -1293,9 +1559,10 @@ export default function InstrumentModels() {
|
||||
}, [state])
|
||||
|
||||
const VIEW_BTNS: { key: ViewMode; Icon: typeof Network; label: string }[] = [
|
||||
{ key: 'dag', Icon: Network, label: 'DAG' },
|
||||
{ key: 'table', Icon: Table2, label: 'Tableau' },
|
||||
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
|
||||
{ key: 'dag', Icon: Network, label: 'DAG' },
|
||||
{ key: 'table', Icon: Table2, label: 'Tableau' },
|
||||
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
|
||||
{ key: 'calibration', Icon: Activity, label: 'Calibration' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -1437,9 +1704,10 @@ export default function InstrumentModels() {
|
||||
{/* Main content */}
|
||||
{state && (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode}/>}
|
||||
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
|
||||
{view === 'timeline' && <TimelineView instrument={instrument}/>}
|
||||
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}}/>}
|
||||
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
|
||||
{view === 'timeline' && <TimelineView instrument={instrument}/>}
|
||||
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1484,7 +1752,10 @@ export default function InstrumentModels() {
|
||||
<SyncPanel
|
||||
instrument={instrument}
|
||||
onClose={() => setShowSync(false)}
|
||||
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
|
||||
onApplied={(atDate) => {
|
||||
setShowSync(false)
|
||||
setRefreshKey(k => k + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user