feat: simulation portfolio surveillance + patterns grid/filter UI
Portfolio Monitor (v4.4): - New portfolio_risk.py service: concentration by asset_class, directional conflict detection (same underlying, opposite directions), overweight alerts - AI agent (Step 7b) runs GPT-4o-mini after each cycle log: assessment + prioritized actions + rebalance suggestion, persisted in system_logs - GET /api/journal/portfolio-risk — full risk breakdown + latest AI monitor reco - POST /api/journal/trade-check — pre-entry conflict & concentration check - asset_class column added to trade_entry_prices (auto-migration + populated at INSERT) - Journal: new "Risque Sim." tab with concentration bars, conflict alerts, AI recommendations; red badge on tab when danger alerts exist PatternEditor: - Grid view default (2-3 cols responsive), list toggle - Asset class filter chips (energy/metals/agri/equities/indices/forex/rates) - Sort: Date (default) / Score IA / Prob. - Period filter: Tout / 7j / 30j - Result count badge when filters active Doc: v4.3 → v4.4, updated Journal/PatternEditor/cycle steps/schema/glossary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -341,6 +341,7 @@ export const CYCLE_REFRESH_KEYS = [
|
||||
['journal-macro'],
|
||||
['geo-risk-score'],
|
||||
['pattern-matches'],
|
||||
['journal-portfolio-risk'],
|
||||
]
|
||||
|
||||
export const useTriggerCycle = () => {
|
||||
@@ -458,6 +459,19 @@ export const useSaveExitDefaults = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useSimPortfolioRisk = () =>
|
||||
useQuery({
|
||||
queryKey: ['journal-portfolio-risk'],
|
||||
queryFn: () => api.get('/journal/portfolio-risk').then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
export const useTradeCheck = () =>
|
||||
useMutation({
|
||||
mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) =>
|
||||
api.post('/journal/trade-check', body).then(r => r.data),
|
||||
})
|
||||
|
||||
// ── Risk Profiles ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const useRiskProfiles = () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, Fragment } from 'react'
|
||||
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock } from 'lucide-react'
|
||||
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock, ShieldAlert, PieChart } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
|
||||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, api } from '../hooks/useApi'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
@@ -1234,21 +1234,228 @@ function CyclesSection() {
|
||||
|
||||
// ── Page principale ────────────────────────────────────────────────────────────
|
||||
|
||||
const ASSET_CLASS_COLORS: Record<string, string> = {
|
||||
energy: '#f97316', metals: '#eab308', agriculture: '#84cc16',
|
||||
equities: '#22c55e', indices: '#3b82f6', forex: '#8b5cf6', rates: '#06b6d4',
|
||||
unknown: '#64748b',
|
||||
}
|
||||
|
||||
function SimPortfolioRiskPanel() {
|
||||
const { data, isLoading, refetch } = useSimPortfolioRisk()
|
||||
const risk = data as any
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card animate-pulse">
|
||||
<div className="h-5 bg-dark-700 rounded w-48 mb-4" />
|
||||
<div className="space-y-2">{[1, 2, 3].map(i => <div key={i} className="h-8 bg-dark-700 rounded" />)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!risk || risk.open_count === 0) {
|
||||
return (
|
||||
<div className="card text-center py-8 text-slate-500">
|
||||
<PieChart className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||||
<div className="text-sm">Aucune position ouverte dans le portefeuille simulé</div>
|
||||
<div className="text-xs mt-1">Les trades logués apparaîtront ici après le prochain cycle</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const concentration: Record<string, any> = risk.concentration ?? {}
|
||||
const alerts: any[] = risk.alerts ?? []
|
||||
const conflicts: any[] = risk.conflicts ?? []
|
||||
const aiMonitor: any = risk.ai_monitor
|
||||
const aiTs: string = risk.ai_monitor_ts
|
||||
|
||||
const dangers = alerts.filter((a: any) => a.level === 'danger')
|
||||
const warnings = alerts.filter((a: any) => a.level === 'warning')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-white">Portefeuille Simulé — Analyse de Risque</span>
|
||||
<span className="text-xs text-slate-500">{risk.open_count} positions ouvertes</span>
|
||||
{dangers.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-900/30 border border-red-600/40 text-red-400 rounded px-1.5 py-0.5">
|
||||
<ShieldAlert className="w-3 h-3" /> {dangers.length} CONFLIT{dangers.length > 1 ? 'S' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => refetch()} className="text-slate-600 hover:text-slate-400 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Asset class concentration bars */}
|
||||
<div className="card">
|
||||
<div className="text-xs text-slate-500 mb-3 font-semibold uppercase tracking-wide">Répartition par classe d'actif</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(concentration)
|
||||
.sort(([, a]: any, [, b]: any) => b.pct - a.pct)
|
||||
.map(([ac, data]: [string, any]) => {
|
||||
const color = ASSET_CLASS_COLORS[ac] ?? ASSET_CLASS_COLORS.unknown
|
||||
const exposure = risk.direction_exposure?.[ac] ?? {}
|
||||
return (
|
||||
<div key={ac}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold w-20 capitalize" style={{ color }}>{ac}</span>
|
||||
<span className="text-[10px] text-slate-500">{data.tickers?.slice(0, 3).join(', ')}{data.tickers?.length > 3 ? '…' : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{exposure.bullish > 0 && (
|
||||
<span className="text-emerald-400 flex items-center gap-0.5">
|
||||
<TrendingUp className="w-2.5 h-2.5" /> {exposure.bullish}
|
||||
</span>
|
||||
)}
|
||||
{exposure.bearish > 0 && (
|
||||
<span className="text-red-400 flex items-center gap-0.5">
|
||||
<TrendingDown className="w-2.5 h-2.5" /> {exposure.bearish}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-400 font-mono font-bold">{data.pct}%</span>
|
||||
<span className="text-slate-600">({data.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${data.pct}%`, background: `${color}cc` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{/* Conflicts */}
|
||||
{conflicts.map((c: any, i: number) => (
|
||||
<div key={i} className="card border-red-700/40 bg-red-900/10">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold text-red-300 mb-1">
|
||||
Conflit directionnel — {c.underlying}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{c.trades.map((t: any) => (
|
||||
<div key={t.id} className="flex items-center gap-2 text-[11px]">
|
||||
<span className={clsx('font-bold font-mono',
|
||||
t.direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{t.direction === 'bullish' ? '▲' : '▼'} #{t.id}
|
||||
</span>
|
||||
<span className="text-slate-400">{t.strategy}</span>
|
||||
<span className="text-slate-600">{t.entry_date}</span>
|
||||
{t.pattern_name && (
|
||||
<span className="text-slate-600 truncate max-w-[180px]">{t.pattern_name}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Concentration & overweight warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="card border-amber-700/30 bg-amber-900/10">
|
||||
<div className="text-xs font-semibold text-amber-300 mb-1.5 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> Alertes de concentration
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{warnings.map((a: any, i: number) => (
|
||||
<div key={i} className="text-[11px] text-amber-200/80 flex items-start gap-1.5">
|
||||
<span className="text-amber-500 shrink-0">•</span>
|
||||
{a.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Monitor recommendations */}
|
||||
{aiMonitor && (
|
||||
<div className="card border-blue-700/30 bg-blue-900/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
|
||||
<Brain className="w-3.5 h-3.5" /> Recommandations IA — Moniteur de portefeuille
|
||||
</div>
|
||||
{aiTs && (
|
||||
<span className="text-[10px] text-slate-600">{aiTs.slice(0, 16).replace('T', ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
{aiMonitor.assessment && (
|
||||
<p className="text-xs text-slate-300 mb-2">{aiMonitor.assessment}</p>
|
||||
)}
|
||||
{aiMonitor.actions?.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{aiMonitor.actions.map((action: any, i: number) => (
|
||||
<div key={i} className={clsx(
|
||||
'flex items-start gap-2 text-[11px] rounded px-2 py-1',
|
||||
action.priority === 'high'
|
||||
? 'bg-red-900/20 border border-red-700/20 text-red-300'
|
||||
: 'bg-slate-800/40 border border-slate-700/20 text-slate-300'
|
||||
)}>
|
||||
<span className="shrink-0 font-bold">
|
||||
{action.type === 'close_trade' ? '🔒' : action.type === 'rebalance' ? '⚖️' : '👁'}
|
||||
</span>
|
||||
<div>
|
||||
{action.underlying && <span className="font-mono font-bold mr-1">{action.underlying}</span>}
|
||||
{action.trade_id && <span className="text-slate-500 mr-1">#{action.trade_id}</span>}
|
||||
{action.reason}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{aiMonitor.rebalance_suggestion && (
|
||||
<div className="text-[11px] text-blue-300/70 border-t border-blue-700/20 pt-2 mt-1">
|
||||
⚖️ {aiMonitor.rebalance_suggestion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alerts.length === 0 && (
|
||||
<div className="card border-emerald-700/30 bg-emerald-900/10 text-center py-3">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
|
||||
<div className="text-xs text-emerald-300">Portefeuille équilibré — aucun conflit ni sur-concentration détectés</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
|
||||
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
|
||||
{ key: 'risk', label: 'Risque Sim.', icon: ShieldAlert },
|
||||
{ key: 'mtm', label: 'Ouverts', icon: TrendingUp },
|
||||
{ key: 'closed', label: 'Fermés', icon: Lock },
|
||||
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
|
||||
] as const
|
||||
|
||||
export default function JournalDeBord() {
|
||||
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo'>('cycles')
|
||||
const [tab, setTab] = useState<'cycles' | 'macro' | 'risk' | 'mtm' | 'closed' | 'geo'>('cycles')
|
||||
const [days, setDays] = useState(15)
|
||||
const [confirmReset, setConfirmReset] = useState(false)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [resetMsg, setResetMsg] = useState('')
|
||||
const { data: summary, refetch: refetchSummary } = useJournalSummary()
|
||||
const { data: riskData } = useSimPortfolioRisk()
|
||||
const riskAlertCount = (riskData as any)?.alerts?.filter((a: any) => a.level === 'danger').length ?? 0
|
||||
const qc = useQueryClient()
|
||||
|
||||
const s = summary as any
|
||||
@@ -1371,13 +1578,18 @@ export default function JournalDeBord() {
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
<button key={key} onClick={() => setTab(key as any)}
|
||||
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
|
||||
'bg-blue-600 text-white': tab === key,
|
||||
'text-slate-400 hover:text-slate-200': tab !== key,
|
||||
})}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
{key === 'risk' && riskAlertCount > 0 && (
|
||||
<span className="ml-0.5 text-[10px] font-bold bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center shrink-0">
|
||||
{riskAlertCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1385,6 +1597,7 @@ export default function JournalDeBord() {
|
||||
{/* Content */}
|
||||
{tab === 'cycles' && <CyclesSection />}
|
||||
{tab === 'macro' && <MacroHistorySection days={days} />}
|
||||
{tab === 'risk' && <SimPortfolioRiskPanel />}
|
||||
{tab === 'mtm' && <TradeMtmSection days={days} />}
|
||||
{tab === 'closed' && <ClosedTradesSection days={days} />}
|
||||
{tab === 'geo' && <GeoHistorySection days={days} />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert } from 'lucide-react'
|
||||
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert, LayoutGrid, List, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
function jaccard(a: string[], b: string[]): number {
|
||||
if (!a.length && !b.length) return 0
|
||||
@@ -15,6 +15,10 @@ function jaccard(a: string[], b: string[]): number {
|
||||
|
||||
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
|
||||
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
|
||||
const ASSET_CLASS_LABELS: Record<string, string> = {
|
||||
energy: '⚡ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
|
||||
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex', rates: '📉 Taux',
|
||||
}
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
|
||||
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
|
||||
@@ -635,6 +639,10 @@ export default function PatternEditor() {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showAiSuggest, setShowAiSuggest] = useState(false)
|
||||
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [sortBy, setSortBy] = useState<'date' | 'score' | 'prob'>('date')
|
||||
const [filterAsset, setFilterAsset] = useState<string>('all')
|
||||
const [filterDays, setFilterDays] = useState<number | null>(null)
|
||||
|
||||
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
|
||||
const aiScoreMap = useMemo(() => {
|
||||
@@ -672,18 +680,40 @@ export default function PatternEditor() {
|
||||
return map
|
||||
}, [simData])
|
||||
|
||||
const displayPatterns = (patterns ?? []).filter((p: any) => {
|
||||
if (tab === 'custom') return p.source === 'custom'
|
||||
if (tab === 'builtin') return p.source === 'builtin'
|
||||
return true
|
||||
})
|
||||
const displayPatterns = useMemo(() => {
|
||||
let result = (patterns ?? []).filter((p: any) => {
|
||||
if (tab === 'custom') return p.source === 'custom'
|
||||
if (tab === 'builtin') return p.source === 'builtin'
|
||||
return true
|
||||
})
|
||||
if (filterAsset !== 'all') {
|
||||
result = result.filter((p: any) => p.asset_class === filterAsset)
|
||||
}
|
||||
if (filterDays !== null) {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - filterDays)
|
||||
result = result.filter((p: any) => p.created_at && new Date(p.created_at) >= cutoff)
|
||||
}
|
||||
switch (sortBy) {
|
||||
case 'score':
|
||||
return [...result].sort((a, b) => (aiScoreMap[b.id] ?? 0) - (aiScoreMap[a.id] ?? 0))
|
||||
case 'prob':
|
||||
return [...result].sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0))
|
||||
default:
|
||||
return [...result].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
||||
}
|
||||
}, [patterns, tab, filterAsset, filterDays, sortBy, aiScoreMap])
|
||||
|
||||
const handleSave = (form: any) => {
|
||||
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
|
||||
}
|
||||
|
||||
const gridClass = viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'
|
||||
: 'space-y-3'
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="p-6 space-y-4">
|
||||
{showAiSuggest && (
|
||||
<AiSuggestModal
|
||||
onClose={() => setShowAiSuggest(false)}
|
||||
@@ -698,7 +728,7 @@ export default function PatternEditor() {
|
||||
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{patterns?.length ?? 0} patterns actifs ·{' '}
|
||||
{patterns?.length ?? 0} patterns ·{' '}
|
||||
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
|
||||
</p>
|
||||
</div>
|
||||
@@ -729,26 +759,105 @@ export default function PatternEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||||
{[
|
||||
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
|
||||
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
|
||||
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
|
||||
].map(({ key, label }) => (
|
||||
<button key={key} onClick={() => setTab(key as any)}
|
||||
className={clsx('px-3 py-1.5 rounded text-sm', {
|
||||
'bg-blue-600 text-white': tab === key,
|
||||
'text-slate-400 hover:text-slate-200': tab !== key,
|
||||
{/* Source tabs + controls */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded">
|
||||
{[
|
||||
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
|
||||
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
|
||||
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
|
||||
].map(({ key, label }) => (
|
||||
<button key={key} onClick={() => setTab(key as any)}
|
||||
className={clsx('px-3 py-1.5 rounded text-sm', {
|
||||
'bg-blue-600 text-white': tab === key,
|
||||
'text-slate-400 hover:text-slate-200': tab !== key,
|
||||
})}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Sort */}
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded items-center">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500 ml-1 mr-0.5" />
|
||||
{([
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'score', label: 'Score IA' },
|
||||
{ key: 'prob', label: 'Prob.' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button key={key} onClick={() => setSortBy(key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': sortBy === key,
|
||||
'text-slate-400 hover:text-slate-200': sortBy !== key,
|
||||
})}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Period filter */}
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded">
|
||||
{([
|
||||
{ key: null, label: 'Tout' },
|
||||
{ key: 7, label: '7j' },
|
||||
{ key: 30, label: '30j' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button key={String(key)} onClick={() => setFilterDays(key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': filterDays === key,
|
||||
'text-slate-400 hover:text-slate-200': filterDays !== key,
|
||||
})}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View mode */}
|
||||
<div className="flex gap-1 bg-dark-700 p-1 rounded">
|
||||
<button onClick={() => setViewMode('grid')}
|
||||
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setViewMode('list')}
|
||||
className={clsx('p-1.5 rounded transition-colors', viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset class filter chips */}
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<button onClick={() => setFilterAsset('all')}
|
||||
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
|
||||
'bg-slate-600/40 border-slate-500/60 text-white': filterAsset === 'all',
|
||||
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== 'all',
|
||||
})}>
|
||||
Tous types
|
||||
</button>
|
||||
{ASSET_CLASSES.map(ac => (
|
||||
<button key={ac} onClick={() => setFilterAsset(filterAsset === ac ? 'all' : ac)}
|
||||
className={clsx('px-2.5 py-1 rounded border text-xs transition-all', {
|
||||
'bg-blue-600/30 border-blue-500/60 text-blue-300': filterAsset === ac,
|
||||
'border-slate-700/40 text-slate-500 hover:border-slate-500 hover:text-slate-300': filterAsset !== ac,
|
||||
})}>
|
||||
{label}
|
||||
{ASSET_CLASS_LABELS[ac]}
|
||||
</button>
|
||||
))}
|
||||
{(filterAsset !== 'all' || filterDays !== null) && (
|
||||
<span className="text-xs text-slate-600 ml-1">
|
||||
{displayPatterns.length} résultat{displayPatterns.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
|
||||
<div className={gridClass}>
|
||||
{[1,2,3,4,5,6].map(i => <div key={i} className="card animate-pulse h-24 bg-dark-700" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className={gridClass}>
|
||||
{displayPatterns.map((p: any) => (
|
||||
<PatternCard key={p.id}
|
||||
p={p}
|
||||
@@ -761,9 +870,15 @@ export default function PatternEditor() {
|
||||
/>
|
||||
))}
|
||||
{displayPatterns.length === 0 && (
|
||||
<div className="card text-center py-10 text-slate-500">
|
||||
<div className="card text-center py-10 text-slate-500 col-span-full">
|
||||
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||||
<div>Aucun pattern personnalisé</div>
|
||||
<div>
|
||||
{filterAsset !== 'all'
|
||||
? `Aucun pattern dans "${ASSET_CLASS_LABELS[filterAsset]}"`
|
||||
: filterDays !== null
|
||||
? `Aucun pattern dans les ${filterDays} derniers jours`
|
||||
: 'Aucun pattern personnalisé'}
|
||||
</div>
|
||||
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user