+
{intitule || "Opération sans intitulé"}
{code &&
Code {code}
}
@@ -171,6 +171,8 @@ export function PublicitesClient({
header: "Opération",
sortValue: (p) => p.intitule,
cell: (p) =>
,
+ sticky: true,
+ grow: true,
footer: (rows) =>
`Total${paginee ? " de la page" : ""} · ${fmtEntier(rows.length)} publicité${rows.length > 1 ? "s" : ""}`,
},
@@ -261,6 +263,8 @@ export function PublicitesClient({
header: "Opération",
sortValue: (h) => h.intitule,
cell: (h) =>
,
+ sticky: true,
+ grow: true,
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
},
{
diff --git a/src/app/globals.css b/src/app/globals.css
index 93f9810..1266020 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -578,4 +578,35 @@
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
-}
\ No newline at end of file
+}
+/* ── Tableau de données commun (components/ui/data-table.tsx) ──
+ Fonds opaques partout : les colonnes figées recouvrent les cellules qui
+ défilent dessous. La ligne porte la couleur, les cellules en héritent. */
+.dt-table {
+ /* Une ligne sur deux, plus soutenue : suivre une ligne sur un écran large. */
+ --dt-zebra: color-mix(in srgb, var(--text-primary) 3.5%, var(--bg-surface));
+ /* Survol teinté de la couleur d'accent : la ligne pointée se distingue de l'alternance. */
+ --dt-hover: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
+}
+.dt-row {
+ background-color: var(--bg-surface);
+}
+.dt-row:nth-child(even) {
+ background-color: var(--dt-zebra);
+}
+.dt-row:hover {
+ background-color: var(--dt-hover);
+}
+.dt-sticky {
+ position: sticky;
+ z-index: 1;
+ background-color: inherit;
+}
+/* Ombre portée sur la dernière colonne figée : on voit ce qui passe dessous. */
+.dt-sticky-last {
+ box-shadow: inset -1px 0 0 var(--border-strong), 4px 0 6px -4px rgba(0, 0, 0, 0.25);
+}
+/* Teinte superposée au fond de la ligne (alternance et survol conservés). */
+.dt-highlight {
+ background-image: linear-gradient(var(--accent-bg), var(--accent-bg));
+}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
index cac422f..abf3268 100644
--- a/src/components/ui/data-table.tsx
+++ b/src/components/ui/data-table.tsx
@@ -8,7 +8,7 @@
* en réimplémentaient chacune une partie (13 tableaux faits à la main).
*/
-import { useDeferredValue, useMemo, useState, type ReactNode } from "react";
+import { useDeferredValue, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ChevronUp, ChevronsUpDown, Download, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { SearchInput, Select } from "@/components/ui/form-controls";
@@ -33,6 +33,25 @@ export interface DataColumn
{
footer?: (rows: readonly T[]) => ReactNode;
className?: string;
headerClassName?: string;
+ /**
+ * La colonne prend toute la largeur restante (désignation, libellé…) : les
+ * colonnes de chiffres gardent leur largeur naturelle au lieu de s'étirer
+ * sur un écran large, loin de leur libellé.
+ */
+ grow?: boolean;
+ /**
+ * Colonne figée à gauche pendant le défilement horizontal. Seules les
+ * premières colonnes consécutives peuvent l'être.
+ */
+ sticky?: boolean;
+ /**
+ * Groupe de colonnes (un magasin, « Total »…) : un en-tête commun est
+ * affiché au-dessus des colonnes consécutives du même groupe, et un trait
+ * vertical sépare les groupes.
+ */
+ group?: string;
+ /** Fond légèrement teinté, pour faire ressortir un groupe (le total…). */
+ highlight?: boolean;
}
export interface DataFilter {
@@ -198,6 +217,126 @@ export function DataTable({
const alignement = (a?: "left" | "right" | "center") =>
a === "right" ? "text-right justify-end" : a === "center" ? "text-center justify-center" : "text-left";
+ // Colonnes figées : seulement les premières, consécutives.
+ let nbFigees = 0;
+ while (nbFigees < columns.length && columns[nbFigees].sticky) nbFigees++;
+
+ // Début de groupe : trait vertical à gauche de la colonne.
+ const debutGroupe = (i: number) => Boolean(columns[i].group) && columns[i - 1]?.group !== columns[i].group;
+ const avecGroupes = columns.some((c) => c.group);
+
+ // Décalage gauche de chaque colonne figée = largeur cumulée des précédentes,
+ // mesurée sur l'en-tête et exposée en variables CSS sur le tableau.
+ const tableRef = useRef(null);
+ const [decalages, setDecalages] = useState([]);
+ useLayoutEffect(() => {
+ const table = tableRef.current;
+ if (!table || nbFigees === 0) return;
+ const mesurer = () => {
+ const out: number[] = [];
+ let gauche = 0;
+ for (let i = 0; i < nbFigees; i++) {
+ out.push(gauche);
+ const th = table.querySelector(`thead th[data-col="${i}"]`);
+ gauche += th?.getBoundingClientRect().width ?? 0;
+ }
+ setDecalages((prev) => (prev.length === out.length && prev.every((v, i) => v === out[i]) ? prev : out));
+ };
+ mesurer();
+ const obs = new ResizeObserver(mesurer);
+ obs.observe(table);
+ return () => obs.disconnect();
+ }, [nbFigees, total, columns]);
+
+ const styleFigee = (i: number): CSSProperties | undefined =>
+ i < nbFigees ? { left: decalages[i] ?? 0 } : undefined;
+
+ /** Classes communes à toutes les cellules d'une colonne (en-tête, corps, totaux). */
+ const classesColonne = (c: DataColumn, i: number) => cn(
+ i < nbFigees && "dt-sticky",
+ i === nbFigees - 1 && "dt-sticky-last",
+ c.highlight && "dt-highlight",
+ debutGroupe(i) && "border-l border-[var(--border-strong)]",
+ c.grow && "w-full",
+ // Désignation figée : bornée sur mobile pour laisser voir les chiffres.
+ c.grow && i < nbFigees && "max-w-[45vw] md:max-w-none",
+ );
+
+ const enTete = (c: DataColumn, i: number, rowSpan?: number) => {
+ const triable = Boolean(c.sortValue);
+ const actif = tri?.id === c.id;
+ const contenu = (
+
+ {c.header}
+ {triable && (
+ actif
+ ? (tri!.dir === "asc" ? : )
+ :
+ )}
+
+ );
+ return (
+
+ {triable ? (
+ basculerTri(c.id)}
+ className="hover:text-[var(--text-primary)]"
+ title={typeof c.hint === "string" ? c.hint : undefined}
+ >
+ {contenu}
+
+ ) : c.hint ? (
+ {contenu}
+ ) : contenu}
+
+ );
+ };
+
+ // Première ligne d'en-tête quand il y a des groupes : un titre par groupe,
+ // et les colonnes sans groupe sur deux lignes.
+ const ligneGroupes: ReactNode[] = [];
+ if (avecGroupes) {
+ for (let i = 0; i < columns.length;) {
+ const c = columns[i];
+ if (!c.group) {
+ ligneGroupes.push(enTete(c, i, 2));
+ i++;
+ continue;
+ }
+ let j = i;
+ while (j < columns.length && columns[j].group === c.group) j++;
+ ligneGroupes.push(
+ x.highlight) && "dt-highlight",
+ )}
+ >
+ {c.group}
+ ,
+ );
+ i = j;
+ }
+ }
+
return (
{/* Barre d'outils */}
@@ -252,49 +391,18 @@ export function DataTable
({
className="overflow-auto rounded-xl border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]"
style={{ maxHeight }}
>
-
-
-
- {columns.map((c) => {
- const triable = Boolean(c.sortValue);
- const actif = tri?.id === c.id;
- const contenu = (
-
- {c.header}
- {triable && (
- actif
- ? (tri!.dir === "asc" ? : )
- :
- )}
-
- );
- return (
-
- {triable ? (
- basculerTri(c.id)}
- className="hover:text-[var(--text-primary)]"
- title={typeof c.hint === "string" ? c.hint : undefined}
- >
- {contenu}
-
- ) : c.hint ? (
- {contenu}
- ) : contenu}
-
- );
- })}
-
+
+
+ {avecGroupes ? (
+ <>
+ {ligneGroupes}
+
+ {columns.map((c, i) => (c.group ? enTete(c, i) : null))}
+
+ >
+ ) : (
+ {columns.map((c, i) => enTete(c, i))}
+ )}
{lignesAffichees.map((r, i) => (
@@ -302,17 +410,19 @@ export function DataTable({
key={rowKey(r, i)}
onClick={onRowClick ? () => onRowClick(r) : undefined}
className={cn(
- "border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--bg-elevated)]",
+ "dt-row border-b border-[var(--border)] last:border-b-0",
onRowClick && "cursor-pointer",
)}
>
- {columns.map((c) => (
+ {columns.map((c, ci) => (
@@ -323,15 +433,17 @@ export function DataTable({
))}
{showFooter && (
-
-
- {columns.map((c) => (
+
+
+ {columns.map((c, ci) => (
{c.footer ? c.footer(triees) : null}