From 2404b226280fa5492ef9581918af5513f0f07563 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 09:45:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(grille):=20fige=20les=20colonnes=20d'ident?= =?UTF-8?q?it=C3=A9=20=C3=A0=20gauche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur 2600 px de tableau pour ~1150 px visibles, on voit 8 colonnes sur 18. Dès qu'on défilait vers les totaux ou les mois, la Désignation sortait de l'écran et plus rien ne disait quelle ligne on lisait. Case à cocher, Code interne et Désignation restent désormais collés au bord gauche pendant le défilement horizontal, en-tête compris. Deux conséquences de conception : - La Désignation a été rapprochée du Code interne dans l'ordre des colonnes. Le figeage exige des colonnes contiguës depuis la gauche ; en la laissant après Référence et GTIN, il aurait fallu geler ces deux-là aussi, soit 636 px immobilisés au lieu de 406. L'ordre « code, désignation, puis codes secondaires » est de toute façon celui qu'on lit. - Les cellules figées doivent être OPAQUES, le reste de la ligne glissant dessous. Le survol passe donc par une teinte pleine et non par le voile translucide des cellules ordinaires, et l'en-tête figé reprend le dégradé du thead pour que la bande reste d'un seul tenant. Un filet marque la limite de la zone figée. Les décalages sont calculés par cumul des largeurs réelles et s'arrêtent à la première colonne non figée : masquer le Code interne réajuste tout seul, et une colonne figée ne peut jamais se retrouver à flotter au milieu du tableau. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/features/grid/components/heatmap-grid.tsx | 144 ++++++++++++++---- 1 file changed, 111 insertions(+), 33 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index b5b4570..ffa249b 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -257,6 +257,37 @@ interface VirtualRowProps { columnSizing: Record; } +/** + * Colonnes figées à gauche : celles qui disent QUELLE ligne on lit. + * + * Sans elles, dès qu'on défile vers les totaux ou les mois, la Désignation + * sort de l'écran et plus rien n'identifie la ligne — sur 2600 px de tableau + * pour ~1150 px visibles, c'est la moitié du temps. + * + * `libelle1` a été rapprochée de `codein` dans l'ordre des colonnes : le + * figeage exige des colonnes contiguës depuis la gauche, et geler en plus + * Référence et GTIN aurait immobilisé 636 px au lieu de 406. + */ +const COLONNES_FIGEES = new Set(["select", "codein", "libelle1"]); + +/** + * Décalage `left` de chaque colonne figée, par cumul des largeurs. + * + * On s'arrête à la première colonne non figée : une colonne figée qui ne + * toucherait pas le bord flotterait au milieu du tableau. Si l'utilisateur + * masque « Code interne », le calcul se réajuste tout seul. + */ +function decalagesFiges(colonnes: { id: string; taille: number }[]): Map { + const out = new Map(); + let cumul = 0; + for (const c of colonnes) { + if (!COLONNES_FIGEES.has(c.id)) break; + out.set(c.id, cumul); + cumul += c.taille; + } + return out; +} + const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey, columnSizing }: VirtualRowProps) => { // Uniquement la ligne concernée écoute son propre changement pour l'effet visuel const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme); @@ -274,6 +305,10 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey void columnsKey; void columnSizing; // idem, pour le redimensionnement des colonnes + const cellules = row.getVisibleCells(); + const figes = decalagesFiges(cellules.map((c) => ({ id: c.column.id, taille: c.column.getSize() }))); + const dernierFige = [...figes.keys()].at(-1); + return ( {row.getVisibleCells().map((cell: Cell) => { + const gauche = figes.get(cell.column.id); + const estFige = gauche !== undefined; + const derniereFigee = estFige && cell.column.id === dernierFige; const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3"; const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "prixVente" || cell.column.id === "gamme"; const size = cell.column.getSize(); @@ -298,8 +336,15 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -647,32 +701,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat /> ), }, - { - accessorKey: "reference", - header: "Référence", - size: 110, - cell: ({ getValue }) => ( - ()} - titre="Copier la référence" - className="text-[12px] font-mono opacity-80 group-hover:opacity-100" - style={{ color: "var(--text-secondary)" }} - /> - ), - }, - { - accessorKey: "gtin", - header: "EAN / GTIN", - size: 120, - cell: ({ getValue }) => ( - ()} - titre="Copier l'EAN / GTIN" - className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100" - style={{ color: "var(--text-secondary)" }} - /> - ), - }, { accessorKey: "libelle1", header: "Désignation", @@ -724,6 +752,32 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat ); }, }, + { + accessorKey: "reference", + header: "Référence", + size: 110, + cell: ({ getValue }) => ( + ()} + titre="Copier la référence" + className="text-[12px] font-mono opacity-80 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), + }, + { + accessorKey: "gtin", + header: "EAN / GTIN", + size: 120, + cell: ({ getValue }) => ( + ()} + titre="Copier l'EAN / GTIN" + className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), + }, { accessorKey: "libelle3", header: "Famille", @@ -1238,21 +1292,44 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat }}> {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => { + {(() => { + const figesEntete = decalagesFiges( + headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })), + ); + const dernierFigeEntete = [...figesEntete.keys()].at(-1); + return headerGroup.headers.map((header) => { + const gaucheEntete = figesEntete.get(header.column.id); + const enteteFigee = gaucheEntete !== undefined; const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3"; const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme"; const size = header.getSize(); return ( est un dégradé translucide : sans + // fond propre, les autres intitulés défileraient à + // travers l'en-tête figé. On reprend le même dégradé + // pour que la bande reste d'un seul tenant. + background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))", + borderRight: header.column.id === dernierFigeEntete + ? "1px solid var(--border-strong)" + : undefined, + } : {}), }} onClick={header.column.getToggleSortingHandler()} > @@ -1278,7 +1355,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat )} ); - })} + }); + })()} ))}