mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 21:48:33 +02:00
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:
5 files changed
+240
-113
No files matched your search
@@ -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))} />,
|
||||
|
||||
@@ -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]]),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user