- Extract TradeCard/TradeRow/IBKRTicket + TradeIdeasTab self-contained component to frontend/src/components/TradeIdeas.tsx - Dashboard: remove trade ideas section, MtM section, all related hooks/ state/computations; replace Signaux Géo mini-card with Trades du cycle; add News géo link on Risque Géopolitique card - JournalDeBord: add Idées de trade tab (left of Ouverts) using TradeIdeasTab - Config: reorganize into 5 tabs — IA & Analyse, Auto-Cycle & Logging, Sources, Journal & Sortie, Profils de risque; expose min_score_threshold as a prominent slider in the Auto-Cycle tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
import { useMemo } from 'react'
|
||
import {
|
||
useGeoRiskScore, useAllQuotes,
|
||
useCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
|
||
useTradeMtm, useRiskDashboard,
|
||
useSimPortfolioRisk, useCycleStatus, useKnowledgeState,
|
||
} from '../hooks/useApi'
|
||
import { Clock, Globe, ShieldAlert, ArrowUpRight, Brain } from 'lucide-react'
|
||
import { Link } from 'react-router-dom'
|
||
import clsx from 'clsx'
|
||
import type { Quote } from '../types'
|
||
import { format } from 'date-fns'
|
||
import { fr } from 'date-fns/locale'
|
||
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer } from 'recharts'
|
||
import { scoreColor } from '../components/TradeIdeas'
|
||
|
||
const riskGauge = (score: number) => {
|
||
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'FAIBLE' }
|
||
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODÉRÉ' }
|
||
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'ÉLEVÉ' }
|
||
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTRÊME' }
|
||
}
|
||
|
||
const assetEmoji: Record<string, string> = {
|
||
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
|
||
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
|
||
}
|
||
|
||
function QuoteRow({ q }: { q: Quote }) {
|
||
if (!q.price) return null
|
||
const pos = q.change_pct >= 0
|
||
return (
|
||
<div className="flex items-center justify-between py-1.5 border-b border-slate-700/20 last:border-0">
|
||
<span className="text-xs text-white truncate max-w-[140px]">{q.name || q.symbol}</span>
|
||
<div className="text-right ml-2">
|
||
<div className="text-xs text-white font-mono">{q.price.toFixed(2)}</div>
|
||
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
|
||
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Dashboard() {
|
||
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
|
||
const { data: allQuotes } = useAllQuotes()
|
||
const { data: calendar } = useCalendar()
|
||
const { data: portfolio } = usePortfolioSummary()
|
||
const { data: lastScoresData } = useLastScores()
|
||
const { data: allPatternsData } = useAllPatterns()
|
||
const { data: macroData } = useMacroRegime()
|
||
const { data: tradeMtmData } = useTradeMtm(30)
|
||
const { data: riskDashboard } = useRiskDashboard()
|
||
const { data: simRisk } = useSimPortfolioRisk()
|
||
const { data: cycleStatusData } = useCycleStatus()
|
||
const { data: knowledgeState } = useKnowledgeState()
|
||
|
||
const allPatterns: any[] = allPatternsData ?? []
|
||
|
||
const scoreMap = useMemo(() => {
|
||
const map: Record<string, any> = {}
|
||
for (const sp of (lastScoresData?.scored_patterns ?? [])) {
|
||
if (sp.pattern_id) map[sp.pattern_id] = sp
|
||
}
|
||
return map
|
||
}, [lastScoresData])
|
||
|
||
const macroInfo = useMemo(() => {
|
||
if (!macroData?.scenarios) return null
|
||
const sc = macroData.scenarios
|
||
const dom = sc.dominant ?? 'incertain'
|
||
const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' }
|
||
const assetBias: Record<string, string> = sc.asset_bias?.[dom] ?? {}
|
||
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias }
|
||
}, [macroData])
|
||
|
||
const gauge = riskScore ? riskGauge(riskScore.score) : null
|
||
const radarData = riskScore?.breakdown
|
||
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
|
||
: []
|
||
|
||
return (
|
||
<div className="p-6 space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white">Cockpit GeoOptions</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
{format(new Date(), "EEEE d MMMM yyyy · HH:mm", { locale: fr })}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{portfolio && portfolio.open_positions > 0 && (
|
||
<div className={clsx('text-sm font-bold', portfolio.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
Portfolio: {portfolio.unrealized_pnl >= 0 ? '+' : ''}{portfolio.unrealized_pnl?.toFixed(0)}€
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
|
||
Live
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Risk concentration banner */}
|
||
{(riskDashboard as any)?.concentration_alerts?.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
{((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => (
|
||
<div key={i} className={clsx('flex items-center gap-2 text-xs px-3 py-2 rounded border', {
|
||
'bg-red-900/20 border-red-700/30 text-red-300': alert.level === 'high',
|
||
'bg-amber-900/20 border-amber-700/30 text-amber-300': alert.level === 'warning',
|
||
})}>
|
||
<ShieldAlert className="w-3 h-3 shrink-0" />
|
||
{alert.message}
|
||
<a href="/risk" className="ml-auto text-slate-500 hover:text-slate-300 underline">Risk Dashboard →</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Top row */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{/* Geo Risk */}
|
||
<div className="card col-span-1">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="section-title flex items-center gap-1 mb-0">
|
||
<Globe className="w-3 h-3" /> Risque Géopolitique
|
||
</div>
|
||
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
||
News géo <ArrowUpRight className="w-2.5 h-2.5" />
|
||
</Link>
|
||
</div>
|
||
{riskLoading ? (
|
||
<div className="animate-pulse h-16 bg-dark-600 rounded"></div>
|
||
) : riskScore && gauge ? (
|
||
<>
|
||
<div className={clsx('text-5xl font-bold', gauge.color)}>{riskScore.score}</div>
|
||
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
|
||
<div className="mt-3 bg-dark-700 rounded-full h-2">
|
||
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
|
||
</div>
|
||
<div className="mt-2 space-y-1">
|
||
{riskScore.top_risks?.map(([cat, val]) => (
|
||
<div key={cat} className="flex justify-between text-xs">
|
||
<span className="text-slate-500 capitalize">{(cat as string).replace('_', ' ')}</span>
|
||
<span className="text-slate-300">{Math.round((val as number) * 100)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : <div className="text-slate-500 text-xs">Backend requis</div>}
|
||
</div>
|
||
|
||
{/* Radar */}
|
||
<div className="card col-span-1">
|
||
<div className="section-title">Cartographie risques</div>
|
||
{radarData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<RadarChart data={radarData}>
|
||
<PolarGrid stroke="#1e2d4d" />
|
||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
|
||
<Radar dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
) : <div className="h-40 flex items-center justify-center text-slate-600 text-xs">Chargement...</div>}
|
||
</div>
|
||
|
||
{/* Patterns — AI scores */}
|
||
<div className="card col-span-1 overflow-y-auto max-h-64">
|
||
<div className="section-title flex items-center gap-1">
|
||
<Brain className="w-3 h-3" /> Scores patterns
|
||
{allPatterns.length > 0 && <span className="text-slate-600 text-xs ml-auto font-normal">{allPatterns.length} patterns</span>}
|
||
</div>
|
||
<div className="space-y-1.5 mt-1">
|
||
{allPatterns.length === 0 ? (
|
||
<div className="text-slate-600 text-xs">Backend requis</div>
|
||
) : (
|
||
[...allPatterns]
|
||
.sort((a, b) => {
|
||
const spA = scoreMap[a.id], spB = scoreMap[b.id]
|
||
const effA = spA ? Math.max(...[0, ...(spA.trade_rankings ?? []).map((r: any) => (spA.score ?? 0) + (r.score_delta ?? 0))]) : -1
|
||
const effB = spB ? Math.max(...[0, ...(spB.trade_rankings ?? []).map((r: any) => (spB.score ?? 0) + (r.score_delta ?? 0))]) : -1
|
||
return effB - effA
|
||
})
|
||
.map(p => {
|
||
const sp = scoreMap[p.id]
|
||
const rawScore = sp?.score ?? null
|
||
const bestEff = sp
|
||
? Math.max(rawScore ?? 0, ...(sp.trade_rankings ?? []).map((r: any) =>
|
||
Math.max(0, Math.min(100, (rawScore ?? 0) + (r.score_delta ?? 0)))))
|
||
: null
|
||
return (
|
||
<div key={p.id} className="flex items-center justify-between gap-2">
|
||
<span className="text-xs text-slate-300 truncate flex-1 min-w-0">{p.name}</span>
|
||
{bestEff !== null ? (
|
||
<span className={clsx('text-xs font-bold shrink-0', scoreColor(bestEff))}>{bestEff}</span>
|
||
) : (
|
||
<span className="text-xs text-slate-600 shrink-0">—</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendrier */}
|
||
<div className="card col-span-1 space-y-3">
|
||
<div className="section-title flex items-center gap-1"><Clock className="w-3 h-3" /> Prochains catalyseurs</div>
|
||
<div className="space-y-1.5">
|
||
{calendar?.slice(0, 4).map((ev, i) => (
|
||
<div key={i} className="flex items-center gap-2 text-xs">
|
||
<span className={clsx('badge', {
|
||
'badge-red': ev.importance === 'high',
|
||
'badge-yellow': ev.importance === 'medium',
|
||
'badge-blue': ev.importance === 'low',
|
||
})}>
|
||
{'!'.repeat(ev.importance === 'high' ? 3 : ev.importance === 'medium' ? 2 : 1)}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-white line-clamp-1">{ev.title}</div>
|
||
<div className="text-slate-600">{ev.date?.slice(0, 10)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Command Center: Résumé Opérationnel ── */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
|
||
{/* PnL Simulé */}
|
||
{(() => {
|
||
const trades: any[] = (tradeMtmData as any)?.trades ?? []
|
||
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
|
||
const avgPnl = withPnl.length
|
||
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
|
||
: null
|
||
const winners = withPnl.filter((t: any) => t.pnl_pct > 0).length
|
||
const losers = withPnl.filter((t: any) => t.pnl_pct < 0).length
|
||
return (
|
||
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">📊 PnL Simulé</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className={clsx('text-2xl font-bold font-mono mt-1',
|
||
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{trades.length} trades · <span className="text-emerald-500">{winners}✓</span>{' '}
|
||
<span className="text-red-400">{losers}✗</span>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Risque Simulé */}
|
||
{(() => {
|
||
const risk = simRisk as any
|
||
const alertCount: number = risk?.alerts?.length ?? 0
|
||
const conflictCount: number = risk?.conflicts?.length ?? 0
|
||
const openCount: number = risk?.open_count ?? 0
|
||
return (
|
||
<Link to="/risk" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">🛡️ Risque Simulé</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className={clsx('text-2xl font-bold mt-1', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
|
||
{alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{openCount} positions
|
||
{conflictCount > 0 && <span className="text-red-400 ml-1">· {conflictCount} conflit{conflictCount > 1 ? 's' : ''}</span>}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Dernier Cycle */}
|
||
{(() => {
|
||
const last = (cycleStatusData as any)?.last_cycle
|
||
const ts = last?.ts
|
||
? new Date(last.ts.endsWith('Z') ? last.ts : last.ts + 'Z')
|
||
: null
|
||
const elapsed = ts ? Math.round((Date.now() - ts.getTime()) / 60_000) : null
|
||
const elapsedStr = elapsed === null ? '—'
|
||
: elapsed < 60 ? `${elapsed}min`
|
||
: elapsed < 1440 ? `${Math.round(elapsed / 60)}h`
|
||
: `${Math.round(elapsed / 1440)}j`
|
||
return (
|
||
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">🔄 Dernier Cycle</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className="text-2xl font-bold text-blue-400 mt-1">{elapsedStr}</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{last
|
||
? `${last.patterns_added ?? 0} loggés · géo ${last.geo_score ?? '—'}`
|
||
: 'Aucun cycle enregistré'}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Régime Macro */}
|
||
{(() => {
|
||
const dom = macroInfo?.dominant
|
||
const colorClass = dom === 'growth' ? 'text-emerald-400'
|
||
: dom === 'stagflation' || dom === 'recession' ? 'text-red-400'
|
||
: dom === 'deflation' ? 'text-blue-300'
|
||
: 'text-slate-400'
|
||
return (
|
||
<Link to="/macro" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">🌐 Régime Macro</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className={clsx('text-lg font-bold mt-1', colorClass)}>
|
||
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1 line-clamp-1">
|
||
{macroInfo
|
||
? Object.entries(macroInfo.assetBias).slice(0, 2).map(([k, v]) => `${k}→${v}`).join(' · ')
|
||
: 'Chargement...'}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* ── Command Center: Intelligence & Contexte ── */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
|
||
{/* Super Contexte IA */}
|
||
{(() => {
|
||
const state = (knowledgeState as any)?.state
|
||
const synthesis = state?.synthesis
|
||
const insights = (synthesis?.regime_insights?.length ?? 0)
|
||
+ (synthesis?.pattern_insights?.length ?? 0)
|
||
+ (synthesis?.recurring_mistakes?.length ?? 0)
|
||
const priority = synthesis?.strategic_priorities?.[0]
|
||
const excerpt = typeof priority === 'string' ? priority
|
||
: typeof state?.narrative === 'string' ? state.narrative.slice(0, 90)
|
||
: null
|
||
return (
|
||
<Link to="/super-contexte" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">🧠 Super Contexte</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className="text-sm font-bold text-purple-400 mt-1">
|
||
{state ? `${insights} insights actifs` : 'Aucune synthèse'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1 line-clamp-2">
|
||
{excerpt ?? 'Lancer une synthèse dans Super Contexte'}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Trades du dernier cycle */}
|
||
{(() => {
|
||
const last = (cycleStatusData as any)?.last_cycle
|
||
const added: number = last?.patterns_added ?? 0
|
||
const scored: number = last?.patterns_scored ?? 0
|
||
const geoScore: number | null = last?.geo_score ?? null
|
||
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
|
||
const recent = mtmTrades.slice(0, 3)
|
||
return (
|
||
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">📥 Trades du cycle</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className="text-2xl font-bold text-emerald-400 mt-1">+{added}</div>
|
||
{recent.length > 0 ? (
|
||
<div className="space-y-0.5 mt-1">
|
||
{recent.map((t: any) => (
|
||
<div key={t.id} className="flex justify-between text-[10px]">
|
||
<span className="text-slate-500 truncate font-mono">{t.underlying ?? '—'}</span>
|
||
<span className="text-slate-400 shrink-0 ml-1">
|
||
{t.direction === 'bearish' ? '🐻' : '🐂'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-slate-600 mt-1">
|
||
{scored > 0 ? `${scored} scorés` : 'Aucun cycle enregistré'}
|
||
{geoScore !== null && ` · géo ${geoScore}`}
|
||
</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Meilleur Pattern scoré */}
|
||
{(() => {
|
||
const best = [...allPatterns]
|
||
.map(p => ({ p, sp: scoreMap[p.id] }))
|
||
.filter(x => x.sp?.score != null)
|
||
.sort((a, b) => (b.sp.score ?? 0) - (a.sp.score ?? 0))[0]
|
||
const score = best?.sp?.score ?? null
|
||
const name = best?.p?.name ?? null
|
||
const ticker = best?.sp?.recommended_trade?.underlying ?? null
|
||
return (
|
||
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">⭐ Meilleur Pattern</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className={clsx('text-2xl font-bold font-mono mt-1', score !== null ? scoreColor(score) : 'text-slate-500')}>
|
||
{score ?? '—'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1 line-clamp-1">
|
||
{name ?? 'Aucun scoré'}{ticker ? ` · ${ticker}` : ''}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Patterns Actifs */}
|
||
{(() => {
|
||
const total = allPatterns.length
|
||
const scored = allPatterns.filter(p => scoreMap[p.id]).length
|
||
const unscored = total - scored
|
||
return (
|
||
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="section-title mb-0 text-[10px]">📋 Patterns Actifs</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className="text-2xl font-bold text-slate-200 mt-1">{total}</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
<span className="text-emerald-500">{scored} scorés</span>
|
||
{unscored > 0 && <span className="text-slate-600"> · {unscored} à scorer</span>}
|
||
</div>
|
||
</Link>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* Markets mini overview */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{['energy', 'metals', 'indices', 'forex'].map(cls => (
|
||
<div key={cls} className="card">
|
||
<div className="section-title">{assetEmoji[cls]} {cls}</div>
|
||
{allQuotes?.[cls]?.map(q => <QuoteRow key={q.symbol} q={q} />) ?? (
|
||
<div className="text-xs text-slate-600">Chargement...</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|