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:
OpenSquared
2026-06-18 10:49:22 +02:00
parent fb78be49e5
commit cf45d9fb52

View File

@@ -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>
)}
</>
)}