diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx index 6dfd35e..cda71c2 100644 --- a/src/app/(dashboard)/analytics/client.tsx +++ b/src/app/(dashboard)/analytics/client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useMemo, useTransition, type ReactNode } from "react"; +import { useMemo, useTransition } from "react"; import { Loader2 } from "lucide-react"; import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { Segmented, type TabItem } from "@/components/ui/tabs"; @@ -84,16 +84,6 @@ function Montant({ v }: { v: number }) { return {fmtEur2(v)}; } -/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ -function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { - return ( - - {groupe} - {children} - - ); -} - export function AnalyticsClient({ mode, mois, @@ -126,42 +116,40 @@ export function AnalyticsClient({ sortValue: (r) => r.label, cell: (r) => {r.label}, footer: () => "Total", - className: "min-w-[220px]", + className: "min-w-[140px] sm:min-w-[220px]", + sticky: true, + grow: true, }, ...GROUPES.flatMap((g): DataColumn[] => { const total = g.id === "total"; - const bord = "border-l border-[var(--border)]"; + // En-tête commun, séparation et teinte du total : gérés par DataTable. + const commun = { group: g.titre, highlight: total, align: "right" as const }; return [ { + ...commun, id: `${g.id}-ca`, - header: {libelleMois}, + header: libelleMois, hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`, - align: "right", sortValue: (r) => r[g.ca], cell: (r) => , footer: (rows) => , - className: cn(bord, total && "font-semibold"), - headerClassName: bord, + className: cn(total && "font-semibold"), }, { + ...commun, id: `${g.id}-n1`, - header: ( - - {libelleMoisN1} · - - ), + header: {libelleMoisN1} · , hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMoisN1}, même mois l'année précédente`, - align: "right", sortValue: (r) => r[g.caN1], cell: (r) => , footer: (rows) => , className: cn(total && "font-semibold"), }, { + ...commun, id: `${g.id}-evolution`, - header: Évolution, + header: "Évolution", hint: `Évolution du chiffre d'affaires (${g.titre}) entre ${libelleMoisN1} et ${libelleMois}`, - align: "right", sortValue: (r) => evolution(r[g.ca], r[g.caN1]), cell: (r) => , footer: (rows) => , diff --git a/src/app/(dashboard)/hit-parade/client.tsx b/src/app/(dashboard)/hit-parade/client.tsx index 73cb6ed..c72a5a3 100644 --- a/src/app/(dashboard)/hit-parade/client.tsx +++ b/src/app/(dashboard)/hit-parade/client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useState, useTransition, type ReactNode } from "react"; +import { useState, useTransition } from "react"; import { Loader2 } from "lucide-react"; import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table"; import { Input, Label } from "@/components/ui/form-controls"; @@ -68,56 +68,45 @@ function CelluleStock({ v }: { v: number }) { ); } -/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */ -function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) { - return ( - - {groupe} - {children} - - ); -} - function colonnesGroupe(g: Groupe): DataColumn[] { const total = g.id === "total"; - const bord = "border-l border-[var(--border)]"; + // En-tête commun, séparation et teinte du total : gérés par DataTable. + const commun = { group: g.titre, highlight: total, align: "right" as const }; return [ { + ...commun, id: `${g.id}-qte`, - header: Qté, + header: "Qté", hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`, - align: "right", sortValue: r => r[g.qte], cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide), footer: rows => fmtEntier(somme(rows, g.qte)), - className: cn(bord, total && "font-semibold"), - headerClassName: bord, + className: cn(total && "font-semibold"), }, { + ...commun, id: `${g.id}-ca`, - header: CA TTC, + header: "CA TTC", hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`, - align: "right", sortValue: r => r[g.ca], cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide), footer: rows => fmtEur2(somme(rows, g.ca)), - className: cn("whitespace-nowrap", total && "font-semibold"), + className: cn(total && "font-semibold"), }, { + ...commun, id: `${g.id}-marge`, - header: % marge, + header: "% marge", hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`, - align: "right", sortValue: r => tauxMarge(r[g.ca], r[g.marge]), cell: r => , footer: rows => , - className: "whitespace-nowrap", }, { + ...commun, id: `${g.id}-stock`, - header: Stock, + header: "Stock", hint: `Stock actuel (${g.nom}). En rouge : stock négatif`, - align: "right", sortValue: r => r[g.stock], cell: r => , footer: rows => fmtEntier(somme(rows, g.stock)), @@ -133,6 +122,17 @@ const COLONNES: DataColumn[] = [ cell: r => {r.codein}, footer: () => "Total", className: "whitespace-nowrap", + sticky: true, + }, + { + id: "libelle", + header: "Désignation", + sortValue: r => r.libelle.trim(), + cell: r => {r.libelle.trim()}, + footer: rows => {fmtEntier(rows.length)} articles, + className: "min-w-[140px] sm:min-w-[220px]", + sticky: true, + grow: true, }, { id: "reference", @@ -142,14 +142,6 @@ const COLONNES: DataColumn[] = [ cell: r => (r.reference ? {r.reference} : vide), className: "whitespace-nowrap", }, - { - id: "libelle", - header: "Désignation", - sortValue: r => r.libelle.trim(), - cell: r => {r.libelle.trim()}, - footer: rows => {fmtEntier(rows.length)} articles, - className: "min-w-[220px]", - }, { id: "fournisseur", header: "Fournisseur", @@ -212,16 +204,16 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) { feuille: "Meilleures ventes", fichier: `meilleures-ventes_${dateDebut}_${dateFin}`, entetes: [ - "Code", "Référence", "Désignation", "Fournisseur", "Nomenclature", + "Code", "Désignation", "Référence", "Fournisseur", "Nomenclature", ...GROUPES.flatMap(g => [ `Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`, ]), ], - largeurs: [12, 18, 40, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], + largeurs: [12, 40, 18, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])], lignes: rows.map(r => [ r.codein, - r.reference, r.libelle.trim(), + r.reference, r.fournisseur, r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "", ...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]), diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index a15f7f8..a73832f 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -76,7 +76,7 @@ function StatutBadge({ statut }: { statut?: string }) { /** Intitulé d'abord, code de l'opération en petit dessous. */ function Operation({ intitule, code }: { intitule: string; code: string }) { return ( -
+
{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 ? ( + + ) : 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 ? ( - - ) : 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) => ( {showFooter && ( - - - {columns.map((c) => ( + + + {columns.map((c, ci) => (
@@ -323,15 +433,17 @@ export function DataTable({ ))}
{c.footer ? c.footer(triees) : null}