From cf45d9fb5269e1c5dd3c7398d3cfcc8fe8c99d99 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 18 Jun 2026 10:49:22 +0200 Subject: [PATCH] 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 --- frontend/src/pages/Dashboard.tsx | 255 ++++++++++++++++++++++++++++--- 1 file changed, 238 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6776d85..69f8e53 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 } | 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 ( + + = 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 */} + {rank} + {/* Pattern + trade */} + +
{patternName}
+
+ {assetClass} + {trade.underlying && {trade.underlying}} + {trade.isRecommended && ★ IA} +
+ + {/* Strategy */} + + {trade.strategy + ? {trade.strategy} + : } + + {/* Score */} + + {effectiveScore !== null ? ( +
+ {effectiveScore} + {scoreDelta !== null && scoreDelta !== 0 && ( + 0 ? 'text-emerald-400' : 'text-red-400')}> + {scoreDelta > 0 ? '+' : ''}{scoreDelta} + + )} +
+ ) : à scorer} + + {/* Score bar */} + + {effectiveScore !== null && ( +
+
+
+ )} + + {/* EV */} + + {evNet !== null + ? = 0 ? 'text-emerald-400' : 'text-orange-400')}>EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}% + : } + + {/* Gain */} + + {gainPct > 0 ? `${gainPct}%` : '—'} + + {/* Max loss / target */} + + {maxLoss != null &&
-{Math.abs(maxLoss)}€
} + {target != null &&
+{target}€
} + + {/* Profile */} + + {matchedProfile !== undefined && ( + matchedProfile + ? ● {matchedProfile.name} + : ✗ Aucun + )} + + {/* Macro */} + {bd.label} + {/* Expand chevron */} + + {expanded ? : } + + + {expanded && ( + + +
+ {/* Col 1: score justification */} +
+ {scoreInfo?.key_catalyst && ( +
+ 🔑 +

{scoreInfo.key_catalyst}

+
+ )} + {rankRationale &&

{rankRationale}

} + {scoreInfo?.summary &&

{scoreInfo.summary}

} + {scoreInfo?.buckets?.length > 0 && ( + + )} +
+ {/* Col 2: actions + contra + timing */} +
+ {scoreInfo?.contra_signals?.length > 0 && ( +
+
Contra signals
+ {scoreInfo.contra_signals.slice(0, 3).map((cs: any, i: number) => ( +
{cs.title ?? cs}
+ ))} +
+ )} + {macroInfo && macroInfo.dominant !== 'incertain' && ( +
+ {macroInfo.emoji} {macroInfo.label} + · + {bd.label} +
+ )} + {addedInfo && ( +
+ + Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })} +
+ )} + +
+
+ + + )} + + ) +} + 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 */}
- {[5, 10, 20].map(n => ( + {[5, 10, 20, 0].map(n => ( ))}
+ {/* View toggle */} +
+ + +
+ {/* Score button */} {aiStatus?.enabled ? (
- {/* Scored trade cards */} + {/* Scored trades — cards or grid */} {topScored.length > 0 && ( -
- {topScored.map((item, i) => ( - - ))} -
+ viewMode === 'cards' ? ( +
+ {topScored.map((item, i) => ( + + ))} +
+ ) : ( +
+ + + + + + + + + + + + + + + + + + {topScored.map((item, i) => ( + + ))} + +
#Pattern / TickerStratégieScoreEVGainMax/CibleProfilMacro
+
+ ) )} - {/* Unscored trade cards */} + {/* Unscored trades */} {allUnscored.length > 0 && ( <> {topScored.length > 0 && ( @@ -799,11 +1008,23 @@ export default function Dashboard() { )} -
- {allUnscored.map((item, i) => ( - - ))} -
+ {viewMode === 'cards' ? ( +
+ {allUnscored.map((item, i) => ( + + ))} +
+ ) : ( +
+ + + {allUnscored.map((item, i) => ( + + ))} + +
+
+ )} )}