Tableaux plus lisibles : colonne extensible, lignes alternées, groupes par magasin, colonnes figées

DataTable gagne quatre options de colonne :
- grow : la désignation prend la largeur restante, les chiffres restent
  groupés au lieu de s'étirer sur un écran large ;
- sticky : Code et Désignation restent visibles au défilement horizontal
  (désignation bornée à 45 % de la largeur sur mobile) ;
- group : en-tête commun par magasin et trait de séparation entre groupes ;
- highlight : fond teinté pour le groupe « Total nos magasins ».
Toutes les lignes alternent désormais de couleur, survol teinté.

Appliqué à Meilleures ventes (Référence déplacée après la Désignation),
Ventes par mois et Publicités.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
This commit is contained in:
Claude committed 2026-10-06 09:38:35 +00:00
1 parent 4340e2999b
commit 4c67fb7171
5 files changed
+240 -113

No files matched your search

+13 -25
View File
@@ -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 <span className={cn("whitespace-nowrap", v === 0 && "text-[var(--text-muted)]")}>{fmtEur2(v)}</span>;
}
/** En-tête sur deux lignes : magasin au-dessus, mesure en dessous. */
function EnTete({ groupe, children }: { groupe: string; children: ReactNode }) {
return (
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span className="inline-flex items-center gap-1">{children}</span>
</span>
);
}
export function AnalyticsClient({
mode,
mois,
@@ -126,42 +116,40 @@ export function AnalyticsClient({
sortValue: (r) => r.label,
cell: (r) => <span className="font-medium">{r.label}</span>,
footer: () => "Total",
className: "min-w-[220px]",
className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
grow: true,
},
...GROUPES.flatMap((g): DataColumn<AnalyticsRow>[] => {
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: <EnTete groupe={g.titre}>{libelleMois}</EnTete>,
header: libelleMois,
hint: `Chiffre d'affaires TTC (${g.titre}) en ${libelleMois}, retours déduits`,
align: "right",
sortValue: (r) => r[g.ca],
cell: (r) => <Montant v={r[g.ca]} />,
footer: (rows) => <Montant v={somme(rows, g.ca)} />,
className: cn(bord, total && "font-semibold"),
headerClassName: bord,
className: cn(total && "font-semibold"),
},
{
...commun,
id: `${g.id}-n1`,
header: (
<EnTete groupe={g.titre}>
{libelleMoisN1} · <Terme id="n1" />
</EnTete>
),
header: <span className="inline-flex items-center gap-1">{libelleMoisN1} · <Terme id="n1" /></span>,
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) => <Montant v={r[g.caN1]} />,
footer: (rows) => <Montant v={somme(rows, g.caN1)} />,
className: cn(total && "font-semibold"),
},
{
...commun,
id: `${g.id}-evolution`,
header: <EnTete groupe={g.titre}>Évolution</EnTete>,
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) => <DeltaBadge pct={evolution(r[g.ca], r[g.caN1])} />,
footer: (rows) => <DeltaBadge pct={evolution(somme(rows, g.ca), somme(rows, g.caN1))} />,
+27 -35
View File
@@ -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 (
<span className="flex flex-col items-end leading-tight">
<span className="text-xs font-normal text-[var(--text-muted)]">{groupe}</span>
<span>{children}</span>
</span>
);
}
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
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: <EnTete groupe={g.titre}>Qté</EnTete>,
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: <EnTete groupe={g.titre}>CA TTC</EnTete>,
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: <EnTete groupe={g.titre}>% marge</EnTete>,
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 => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
className: "whitespace-nowrap",
},
{
...commun,
id: `${g.id}-stock`,
header: <EnTete groupe={g.titre}>Stock</EnTete>,
header: "Stock",
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
align: "right",
sortValue: r => r[g.stock],
cell: r => <CelluleStock v={r[g.stock]} />,
footer: rows => fmtEntier(somme(rows, g.stock)),
@@ -133,6 +122,17 @@ const COLONNES: DataColumn<Ligne>[] = [
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
footer: () => "Total",
className: "whitespace-nowrap",
sticky: true,
},
{
id: "libelle",
header: "Désignation",
sortValue: r => r.libelle.trim(),
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
className: "min-w-[140px] sm:min-w-[220px]",
sticky: true,
grow: true,
},
{
id: "reference",
@@ -142,14 +142,6 @@ const COLONNES: DataColumn<Ligne>[] = [
cell: r => (r.reference ? <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.reference}</span> : vide),
className: "whitespace-nowrap",
},
{
id: "libelle",
header: "Désignation",
sortValue: r => r.libelle.trim(),
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
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]]),
+5 -1
View File
@@ -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 (
<div className="min-w-[220px]">
<div className="min-w-[140px] sm:min-w-[220px]">
<div className="font-medium text-[var(--text-primary)]">{intitule || "Opération sans intitulé"}</div>
{code && <div className="text-xs text-[var(--text-muted)]">Code {code}</div>}
</div>
@@ -171,6 +171,8 @@ export function PublicitesClient({
header: "Opération",
sortValue: (p) => p.intitule,
cell: (p) => <Operation intitule={p.intitule} code={p.tcr_code} />,
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) => <Operation intitule={h.intitule} code={h.tcr_code} />,
sticky: true,
grow: true,
footer: (rows) => `Total · ${fmtEntier(rows.length)} opération${rows.length > 1 ? "s" : ""}`,
},
{
+32 -1
View File
@@ -578,4 +578,35 @@
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
}
/* ── 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));
}