Merge pull request #11 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i

Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-13 22:25:43 +02:00
commit 1e7c1ba4cd
2 files changed
+171 -37

No files matched your search

+161 -34
View File
@@ -13,11 +13,12 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Eye, EyeOff } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
@@ -56,22 +57,42 @@ const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"qteReseau",
"nbMagasinsReseau",
"tendanceReseau",
"prixMoyenReseau",
"caParMagasinReseau",
"margePctReseau",
]);
function getQlikNetworkSortValue(value: number | null | undefined, columnId: string, sorting: SortingState): number | undefined {
if (value != null) return value;
/**
* Valeur de tri d'une colonne dont la donnée peut manquer : `undefined`, jamais
* zéro — un article que la source ne renseigne pas n'a pas vendu pour 0 €.
*
* Les colonnes qui l'emploient portent `sortUndefined: "last"`, qui garde ces
* lignes en bas dans LES DEUX SENS. Une sentinelle ±Infinity choisie d'après le
* sens de tri courant ne le pouvait pas : TanStack mémorise le résultat de
* l'accesseur dans `row._valuesCache` sans jamais le réévaluer, si bien que la
* sentinelle calculée au premier tri restait figée et renvoyait les lignes sans
* donnée en tête dès qu'on inversait le sens.
*/
const valeurTriable = (value: number | null | undefined): number | undefined => value ?? undefined;
const activeSort = sorting.find((sort) => sort.id === columnId);
if (!activeSort) return undefined;
// TanStack inverse le résultat en tri descendant. On utilise donc une
// sentinelle dépendante du sens de tri pour garder les valeurs absentes
// toujours en bas, en ascendant comme en descendant.
return activeSort.desc ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
/**
* Prix de vente moyen constaté sur le réseau.
*
* Même définition que la fiche produit (`/produits`) : CA réseau ÷ quantité
* vendue. Sans quantité, le rapport n'existe pas — on rend `null` plutôt que
* zéro, un prix nul se confondrait avec un article donné.
*/
function prixMoyenReseau(row: ProductRow): number | null {
const qte = row.qteReseau;
const ca = row.caReseau;
if (qte == null || ca == null || qte <= 0) return null;
return ca / qte;
}
/** Montant en euros au centime — l'usage sur des prix unitaires. */
const fmtEuro2 = (v: number) =>
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
// =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// =========================================================================
@@ -220,7 +241,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
>
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
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 === "gamme";
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();
return (
<td
@@ -451,6 +472,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const activeMagasin = useGridStore((s) => s.activeMagasin);
const showMonthlySales = useGridStore((s) => s.showMonthlySales);
const setShowMonthlySales = useGridStore((s) => s.setShowMonthlySales);
const showPrices = useGridStore((s) => s.showPrices);
const setShowPrices = useGridStore((s) => s.setShowPrices);
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
@@ -665,7 +688,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "caReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caReseau, "caReseau", sorting),
accessorFn: (row) => valeurTriable(row.caReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 90,
cell: ({ row }) => {
@@ -679,7 +703,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "qteReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.qteReseau, "qteReseau", sorting),
accessorFn: (row) => valeurTriable(row.qteReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 80,
cell: ({ row }) => {
@@ -693,7 +718,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "nbMagasinsReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.nbMagasinsReseau, "nbMagasinsReseau", sorting),
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
size: 80,
cell: ({ row }) => {
@@ -734,9 +760,62 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
);
},
},
// Bloc « prix », repliable : le prix réellement pratiqué par le réseau, et
// celui du catalogue. Deux origines différentes, donc deux colonnes — les
// rapprocher d'un seul écart chiffré supposerait une même assiette (HT/TTC,
// remises), ce que les deux sources ne garantissent pas.
...(showPrices ? [
{
id: "prixMoyenReseau",
// Même définition que la fiche produit : CA réseau ÷ quantité vendue.
accessorFn: (row: ProductRow) => valeurTriable(prixMoyenReseau(row)),
sortUndefined: "last" as const,
// Sous-titre tenu court : les en-têtes ne rognent pas leur texte et
// un libellé trop long chevauche la colonne voisine. Le détail
// (« CA réseau ÷ quantité ») est dans l'infobulle des cellules.
header: () => <div className="text-center w-full">PV moyen<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = prixMoyenReseau(row.original);
return (
<div
className="text-center tabular-nums text-[12px] font-bold"
style={{ color: "var(--text-secondary)" }}
title={val != null
? `Prix de vente moyen constaté sur le réseau : CA réseau ÷ quantité vendue (12 mois glissants)`
: "Pas de quantité réseau sur la période : le prix moyen n'a pas de sens"}
>
{val != null ? fmtEuro2(val) : "-"}
</div>
);
},
},
{
id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(row.prixVente),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">PV central<br/><span className="text-[9px] opacity-60">Article</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = row.original.prixVente;
return (
<div
className="text-center tabular-nums text-[12px] font-bold"
style={{ color: "var(--text-primary)" }}
title={val != null
? "Prix de vente central de la fiche article (base Nancy)"
: "Aucun prix de vente central sur la fiche article"}
>
{val != null ? fmtEuro2(val) : "-"}
</div>
);
},
},
] : []),
{
id: "caParMagasinReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caParMagasinReseau, "caParMagasinReseau", sorting),
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">CA / Mag<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 85,
cell: ({ row }) => {
@@ -750,7 +829,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
{
id: "margePctReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.margePctReseau, "margePctReseau", sorting),
accessorFn: (row) => valeurTriable(row.margePctReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full">Marge %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 75,
cell: ({ row }) => {
@@ -887,7 +967,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
},
], [MONTHS_12, activeMagasin, isAdmin, sorting, showMonthlySales]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
// `sorting` ne figure plus en dépendance : plus aucun accesseur ne le lit
// depuis que `sortUndefined` remplace les sentinelles. Les colonnes ne se
// reconstruisent donc plus à chaque clic sur un en-tête.
], [MONTHS_12, activeMagasin, isAdmin, showMonthlySales, showPrices]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
const table = useReactTable({
data: filteredData,
@@ -927,6 +1010,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
// lignes virtuelles (cf. `GridRow`). Calculée une fois par rendu de grille,
// pas une fois par ligne.
const columnsKey = visibleColumns.map((c) => c.id).join("|");
/** Blocs de colonnes affichés — badge du menu « Vues ». */
const nbVues = (showMonthlySales ? 1 : 0) + (showPrices ? 1 : 0);
if (!isMounted) {
return (
@@ -947,22 +1032,64 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2.5">
{/* Repli du bloc mensuel : douze colonnes d'un coup, pas une par une. */}
<button
onClick={() => setShowMonthlySales(!showMonthlySales)}
aria-pressed={showMonthlySales}
title={showMonthlySales
? "Masquer les 12 colonnes de ventes mensuelles — le détail reste accessible en cliquant sur la case de total"
: "Afficher les 12 colonnes de ventes mensuelles"}
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary"
style={showMonthlySales
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
: { background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
>
<CalendarRange className="w-4 h-4" />
<span className="hidden lg:inline">Ventes mensuelles</span>
{showMonthlySales ? <Eye className="w-3.5 h-3.5 opacity-70" /> : <EyeOff className="w-3.5 h-3.5 opacity-70" />}
</button>
{/*
* « Vues » replie des BLOCS de colonnes d'un geste, là où le menu
* « Colonnes » voisin agit colonne par colonne. Douze cellules
* mensuelles ou deux prix ne se masquent pas une par une.
*/}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold border transition-all shadow-sm apple-btn-secondary"
style={nbVues > 0
? { background: "var(--accent-bg)", borderColor: "var(--accent-border)", color: "var(--accent)" }
: { background: "var(--action-secondary-bg)", borderColor: "var(--border-strong)", color: "var(--action-secondary-text)" }}
title="Afficher ou masquer les blocs de colonnes : ventes mensuelles, prix"
aria-label="Afficher ou masquer les blocs de colonnes"
>
{nbVues > 0 ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
<span className="hidden lg:inline">Vues</span>
<span className="text-[11px] font-bold tabular-nums opacity-70">{nbVues}/2</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] z-50">
<DropdownMenuLabel className="text-[11px] uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
Blocs de colonnes
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showMonthlySales}
onCheckedChange={(v) => setShowMonthlySales(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<CalendarRange className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Ventes mensuelles</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
12 mois · détail au clic sur le total
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showPrices}
onCheckedChange={(v) => setShowPrices(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-xs font-semibold">Prix</span>
<span className="text-[10.5px]" style={{ color: "var(--text-muted)" }}>
PV moyen réseau · PV central
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -1020,7 +1147,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
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 === "gamme";
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 (
<th
+10 -3
View File
@@ -30,6 +30,9 @@ interface GridState {
*/
showMonthlySales: boolean;
setShowMonthlySales: (visible: boolean) => void;
/** Bloc des deux colonnes de prix (PV moyen réseau, PV central) affiché ou non. */
showPrices: boolean;
setShowPrices: (visible: boolean) => void;
/** Currently displayed store: "TOTAL" | "292" | "579" */
activeMagasin: string;
@@ -85,7 +88,7 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
type GridPersisted = Pick<
GridState,
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales"
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" | "showPrices"
>;
export const useGridStore = create<GridState>()(
@@ -117,8 +120,10 @@ export const useGridStore = create<GridState>()(
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
showPrices: true,
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
setShowPrices: (visible) => set({ showPrices: visible }),
setActiveMagasin: (code) => {
set({ activeMagasin: code });
@@ -252,10 +257,11 @@ export const useGridStore = create<GridState>()(
const c = etat.filters.code3;
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
}
// Drapeau ajouté après coup : un état persisté antérieur n'en a
// Drapeaux ajoutés après coup : un état persisté antérieur n'en a
// pas, et une valeur `undefined` écraserait la valeur par défaut
// à la fusion (le bloc mensuel disparaîtrait sans raison).
// à la fusion (le bloc disparaîtrait sans raison).
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
return etat;
},
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
@@ -267,6 +273,7 @@ export const useGridStore = create<GridState>()(
columnVisibility: state.columnVisibility,
columnSizing: state.columnSizing,
showMonthlySales: state.showMonthlySales,
showPrices: state.showPrices,
}),
}
)