feat: add grid/table view with expandable rows to Dashboard cockpit
Toggle between Cards (existing) and Grid (new) via icons next to Top N. Grid view: compact table with score bar, EV, gain, max/cible, profil, macro bias per row. Click any row to expand inline (full-width) showing score justification, contra-signals, macro context, and add button. Added 'Tous' option (topN=0) to show all scored patterns without limit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, Fragment } from 'react'
|
||||
import {
|
||||
useGeoRiskScore, useAllQuotes,
|
||||
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
|
||||
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
||||
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
|
||||
} from '../hooks/useApi'
|
||||
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert } from 'lucide-react'
|
||||
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, ShieldAlert, LayoutGrid, List } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { Quote } from '../types'
|
||||
import { format } from 'date-fns'
|
||||
@@ -388,6 +388,172 @@ function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
|
||||
)
|
||||
}
|
||||
|
||||
function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: {
|
||||
item: TradeItem
|
||||
onAdd: (item: TradeItem) => void
|
||||
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
|
||||
addedInfo?: { entry_date: string } | null
|
||||
profiles?: any[]
|
||||
rank: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
|
||||
const effectiveScore = score !== null ? Math.max(0, Math.min(100, score + (scoreDelta ?? 0))) : null
|
||||
const gainPct = expectedMovePct ?? 0
|
||||
const evNet = effectiveScore !== null && gainPct > 0
|
||||
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
|
||||
: null
|
||||
const matchedProfile = useMemo(() => {
|
||||
if (effectiveScore === null || !profiles?.length) return undefined
|
||||
return profiles.find(p => p.enabled && effectiveScore >= p.min_score && gainPct >= p.min_gain_pct) ?? null
|
||||
}, [effectiveScore, gainPct, profiles])
|
||||
const bias = macroInfo?.assetBias[assetClass] ?? 'neutral'
|
||||
const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral']
|
||||
const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur
|
||||
const target = maxLoss != null && gainPct > 0
|
||||
? Math.round(Math.abs(maxLoss) * gainPct / 100)
|
||||
: (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<tr
|
||||
className={clsx(
|
||||
'border-b border-slate-800/60 cursor-pointer transition-colors text-xs',
|
||||
expanded ? 'bg-dark-700/50' : 'hover:bg-dark-700/30',
|
||||
effectiveScore !== null && effectiveScore >= 70 && 'border-l-2 border-l-emerald-600/50',
|
||||
effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70 && 'border-l-2 border-l-yellow-600/30',
|
||||
)}
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<td className="pl-3 py-2 text-slate-700 font-mono text-[10px] w-6">{rank}</td>
|
||||
{/* Pattern + trade */}
|
||||
<td className="px-2 py-2 max-w-[180px]">
|
||||
<div className="text-slate-400 truncate text-[10px] leading-tight">{patternName}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="badge badge-blue text-[10px] py-0">{assetClass}</span>
|
||||
{trade.underlying && <span className="font-mono text-white text-[11px] font-semibold">{trade.underlying}</span>}
|
||||
{trade.isRecommended && <span className="text-yellow-400 text-[10px]">★ IA</span>}
|
||||
</div>
|
||||
</td>
|
||||
{/* Strategy */}
|
||||
<td className="px-2 py-2">
|
||||
{trade.strategy
|
||||
? <span className={clsx('badge text-[10px] py-0', trade.direction === 'bearish' ? 'badge-red' : 'badge-green')}>{trade.strategy}</span>
|
||||
: <span className="text-slate-700 text-[10px]">—</span>}
|
||||
</td>
|
||||
{/* Score */}
|
||||
<td className="px-2 py-2 text-right">
|
||||
{effectiveScore !== null ? (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className={clsx('text-base font-bold leading-none', scoreColor(effectiveScore))}>{effectiveScore}</span>
|
||||
{scoreDelta !== null && scoreDelta !== 0 && (
|
||||
<span className={clsx('text-[10px] font-mono', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-slate-600 text-[10px]">à scorer</span>}
|
||||
</td>
|
||||
{/* Score bar */}
|
||||
<td className="px-2 py-2 w-20">
|
||||
{effectiveScore !== null && (
|
||||
<div className="bg-dark-600 rounded-full h-1.5 w-full">
|
||||
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* EV */}
|
||||
<td className="px-2 py-2 text-right font-mono">
|
||||
{evNet !== null
|
||||
? <span className={clsx('text-[11px] font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%</span>
|
||||
: <span className="text-slate-700 text-[10px]">—</span>}
|
||||
</td>
|
||||
{/* Gain */}
|
||||
<td className="px-2 py-2 text-right font-mono text-slate-400 text-[11px]">
|
||||
{gainPct > 0 ? `${gainPct}%` : '—'}
|
||||
</td>
|
||||
{/* Max loss / target */}
|
||||
<td className="px-2 py-2 text-right text-[10px]">
|
||||
{maxLoss != null && <div className="text-red-400">-{Math.abs(maxLoss)}€</div>}
|
||||
{target != null && <div className="text-emerald-400">+{target}€</div>}
|
||||
</td>
|
||||
{/* Profile */}
|
||||
<td className="px-2 py-2 text-[10px]">
|
||||
{matchedProfile !== undefined && (
|
||||
matchedProfile
|
||||
? <span className="font-semibold" style={{ color: matchedProfile.color }}>● {matchedProfile.name}</span>
|
||||
: <span className="text-red-400/70">✗ Aucun</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Macro */}
|
||||
<td className="px-2 py-2 text-[10px]" style={{ color: bd.color }}>{bd.label}</td>
|
||||
{/* Expand chevron */}
|
||||
<td className="pr-3 py-2 text-slate-600 text-right">
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5 inline" /> : <ChevronDown className="w-3.5 h-3.5 inline" />}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-dark-900/60">
|
||||
<td colSpan={11} className="px-0 py-0 border-b border-blue-700/20">
|
||||
<div className="p-4 grid grid-cols-3 gap-4 text-xs">
|
||||
{/* Col 1: score justification */}
|
||||
<div className="col-span-2">
|
||||
{scoreInfo?.key_catalyst && (
|
||||
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5 mb-3">
|
||||
<span className="text-yellow-400 shrink-0">🔑</span>
|
||||
<p className="text-yellow-200/80 leading-snug">{scoreInfo.key_catalyst}</p>
|
||||
</div>
|
||||
)}
|
||||
{rankRationale && <p className="text-slate-500 italic mb-2">{rankRationale}</p>}
|
||||
{scoreInfo?.summary && <p className="text-slate-400 mb-3">{scoreInfo.summary}</p>}
|
||||
{scoreInfo?.buckets?.length > 0 && (
|
||||
<ScoreJustification buckets={scoreInfo.buckets} keyCatalyst={undefined} />
|
||||
)}
|
||||
</div>
|
||||
{/* Col 2: actions + contra + timing */}
|
||||
<div className="space-y-3">
|
||||
{scoreInfo?.contra_signals?.length > 0 && (
|
||||
<div className="bg-orange-900/10 border border-orange-700/20 rounded p-2 space-y-1">
|
||||
<div className="text-orange-400 font-semibold uppercase text-[9px] tracking-wide">Contra signals</div>
|
||||
{scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => (
|
||||
<div key={i} className="text-orange-300/70 text-[10px] leading-snug">{cs.title ?? cs}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{macroInfo && macroInfo.dominant !== 'incertain' && (
|
||||
<div className="text-[11px]">
|
||||
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
|
||||
<span className="text-slate-600 mx-1">·</span>
|
||||
<span style={{ color: bd.color }}>{bd.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{addedInfo && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAdd(item) }}
|
||||
className={clsx(
|
||||
'w-full flex items-center justify-center gap-1 text-xs rounded py-1.5 transition-all border',
|
||||
addedInfo
|
||||
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
|
||||
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
|
||||
)}>
|
||||
<Plus className="w-3 h-3" />
|
||||
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteRow({ q }: { q: Quote }) {
|
||||
if (!q.price) return null
|
||||
const pos = q.change_pct >= 0
|
||||
@@ -424,6 +590,7 @@ export default function Dashboard() {
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||
const [topN, setTopN] = useState(10)
|
||||
const [viewMode, setViewMode] = useState<'cards' | 'grid'>('cards')
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -538,7 +705,8 @@ export default function Dashboard() {
|
||||
const effScore = (item: TradeItem) =>
|
||||
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
|
||||
scored.sort((a, b) => effScore(b) - effScore(a))
|
||||
return { topScored: scored.slice(0, topN), allUnscored: unscored }
|
||||
const sliced = topN === 0 ? scored : scored.slice(0, topN)
|
||||
return { topScored: sliced, allUnscored: unscored }
|
||||
}, [allPatterns, scoreMap, categoryFilter, topN])
|
||||
|
||||
const handleAdd = (item: TradeItem) => {
|
||||
@@ -754,17 +922,31 @@ export default function Dashboard() {
|
||||
|
||||
{/* Top N */}
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{[5, 10, 20].map(n => (
|
||||
{[5, 10, 20, 0].map(n => (
|
||||
<button key={n} onClick={() => setTopN(n)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': topN === n,
|
||||
'text-slate-400 hover:text-slate-200': topN !== n,
|
||||
})}>
|
||||
Top {n}
|
||||
{n === 0 ? 'Tous' : `Top ${n}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
<button onClick={() => setViewMode('cards')}
|
||||
className={clsx('p-1.5 rounded transition-colors', viewMode === 'cards' ? 'bg-slate-600 text-white' : 'text-slate-500 hover:text-slate-300')}
|
||||
title="Vue cartes">
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setViewMode('grid')}
|
||||
className={clsx('p-1.5 rounded transition-colors', viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-500 hover:text-slate-300')}
|
||||
title="Vue grille">
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Score button */}
|
||||
{aiStatus?.enabled ? (
|
||||
<button onClick={() => scorePatterns({})}
|
||||
@@ -780,16 +962,43 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scored trade cards */}
|
||||
{/* Scored trades — cards or grid */}
|
||||
{topScored.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-3 mb-5">
|
||||
{topScored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
viewMode === 'cards' ? (
|
||||
<div className="grid grid-cols-5 gap-3 mb-5">
|
||||
{topScored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40 mb-5">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700/60 text-slate-600 text-[10px] uppercase tracking-wide">
|
||||
<th className="pl-3 py-2 text-left w-6">#</th>
|
||||
<th className="px-2 py-2 text-left">Pattern / Ticker</th>
|
||||
<th className="px-2 py-2 text-left">Stratégie</th>
|
||||
<th className="px-2 py-2 text-right">Score</th>
|
||||
<th className="px-2 py-2 w-20"></th>
|
||||
<th className="px-2 py-2 text-right">EV</th>
|
||||
<th className="px-2 py-2 text-right">Gain</th>
|
||||
<th className="px-2 py-2 text-right">Max/Cible</th>
|
||||
<th className="px-2 py-2">Profil</th>
|
||||
<th className="px-2 py-2">Macro</th>
|
||||
<th className="pr-3 py-2 w-6"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topScored.map((item, i) => (
|
||||
<TradeRow key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} rank={i + 1} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Unscored trade cards */}
|
||||
{/* Unscored trades */}
|
||||
{allUnscored.length > 0 && (
|
||||
<>
|
||||
{topScored.length > 0 && (
|
||||
@@ -799,11 +1008,23 @@ export default function Dashboard() {
|
||||
<span className="border-b border-slate-700/30 flex-1"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{allUnscored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
{viewMode === 'cards' ? (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{allUnscored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{allUnscored.map((item, i) => (
|
||||
<TradeRow key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} rank={topScored.length + i + 1} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user