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 {
|
import {
|
||||||
useGeoRiskScore, useAllQuotes,
|
useGeoRiskScore, useAllQuotes,
|
||||||
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
|
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
|
||||||
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
||||||
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
|
usePortfolioPositions, useTradeMtm, useRiskProfiles, useRiskDashboard,
|
||||||
} from '../hooks/useApi'
|
} 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 clsx from 'clsx'
|
||||||
import type { Quote } from '../types'
|
import type { Quote } from '../types'
|
||||||
import { format } from 'date-fns'
|
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 }) {
|
function QuoteRow({ q }: { q: Quote }) {
|
||||||
if (!q.price) return null
|
if (!q.price) return null
|
||||||
const pos = q.change_pct >= 0
|
const pos = q.change_pct >= 0
|
||||||
@@ -424,6 +590,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||||
const [topN, setTopN] = useState(10)
|
const [topN, setTopN] = useState(10)
|
||||||
|
const [viewMode, setViewMode] = useState<'cards' | 'grid'>('cards')
|
||||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
|
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -538,7 +705,8 @@ export default function Dashboard() {
|
|||||||
const effScore = (item: TradeItem) =>
|
const effScore = (item: TradeItem) =>
|
||||||
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
|
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
|
||||||
scored.sort((a, b) => effScore(b) - effScore(a))
|
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])
|
}, [allPatterns, scoreMap, categoryFilter, topN])
|
||||||
|
|
||||||
const handleAdd = (item: TradeItem) => {
|
const handleAdd = (item: TradeItem) => {
|
||||||
@@ -754,17 +922,31 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Top N */}
|
{/* Top N */}
|
||||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
<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)}
|
<button key={n} onClick={() => setTopN(n)}
|
||||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||||
'bg-slate-600 text-white': topN === n,
|
'bg-slate-600 text-white': topN === n,
|
||||||
'text-slate-400 hover:text-slate-200': topN !== n,
|
'text-slate-400 hover:text-slate-200': topN !== n,
|
||||||
})}>
|
})}>
|
||||||
Top {n}
|
{n === 0 ? 'Tous' : `Top ${n}`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Score button */}
|
||||||
{aiStatus?.enabled ? (
|
{aiStatus?.enabled ? (
|
||||||
<button onClick={() => scorePatterns({})}
|
<button onClick={() => scorePatterns({})}
|
||||||
@@ -780,16 +962,43 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scored trade cards */}
|
{/* Scored trades — cards or grid */}
|
||||||
{topScored.length > 0 && (
|
{topScored.length > 0 && (
|
||||||
<div className="grid grid-cols-5 gap-3 mb-5">
|
viewMode === 'cards' ? (
|
||||||
{topScored.map((item, i) => (
|
<div className="grid grid-cols-5 gap-3 mb-5">
|
||||||
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
{topScored.map((item, i) => (
|
||||||
))}
|
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||||
</div>
|
))}
|
||||||
|
</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 && (
|
{allUnscored.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{topScored.length > 0 && (
|
{topScored.length > 0 && (
|
||||||
@@ -799,11 +1008,23 @@ export default function Dashboard() {
|
|||||||
<span className="border-b border-slate-700/30 flex-1"></span>
|
<span className="border-b border-slate-700/30 flex-1"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-5 gap-3">
|
{viewMode === 'cards' ? (
|
||||||
{allUnscored.map((item, i) => (
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
{allUnscored.map((item, i) => (
|
||||||
))}
|
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||||
</div>
|
))}
|
||||||
|
</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