From 52b663579f1821900ea60961ed8d86a739e50223 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:16:36 +0000 Subject: [PATCH] =?UTF-8?q?Grille=20:=20colonnes=20de=20prix,=20et=20menu?= =?UTF-8?q?=20=C2=AB=20Vues=20=C2=BB=20pour=20les=20blocs=20de=20colonnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux colonnes s'ajoutent à la suite de la tendance réseau : · PV moyen (Réseau) — calculé par l'application, CA réseau ÷ quantité vendue, exactement la définition déjà retenue par la fiche produit. Sans quantité sur la période, la colonne affiche « - » : le rapport n'existe pas, et un prix à 0 € se lirait comme un article donné. · PV central (Article) — le prix de vente de la fiche article, lu en base (article_infosup.prix_vente_mini), sans retraitement. Les deux restent deux colonnes. En dériver un écart chiffré supposerait une assiette commune — HT/TTC, remises — que les deux sources ne garantissent pas ; le rapprochement se fait à l'œil, en connaissance de cause. Le bouton de repli du bloc mensuel devient un menu « Vues » à deux entrées, ventes mensuelles et prix, chacune repliable. Il agit par BLOCS, là où le menu « Colonnes » voisin agit colonne par colonne : douze cellules mensuelles ou deux prix ne se masquent pas une par une. Les deux états sont persistés. Au passage, le tri des colonnes réseau était faux. La sentinelle ±Infinity choisie d'après le sens de tri courant ne pouvait pas fonctionner : TanStack mémorise le résultat de l'accesseur dans `row._valuesCache` et ne le réévalue jamais, si bien que la valeur calculée au premier tri restait figée et remontait les lignes sans donnée en tête dès qu'on inversait le sens. `sortUndefined: "last"`, traité avant l'inversion, les garde en bas dans les deux sens. Les cinq colonnes réseau existantes en bénéficient, et `sorting` quitte les dépendances des colonnes : plus de reconstruction complète à chaque clic d'en-tête. Vérifié sur build de production : les deux colonnes se placent bien après la tendance, « - » sur quantité réseau nulle comme sur PV central absent, et le tri place les absents en bas au premier clic (décroissant) comme au deuxième (croissant). Menu « Vues » : 31 colonnes avec les deux blocs, 29 sans les prix, 17 sans rien, 19 avec les prix seuls — chaque fois sur toutes les lignes montées, sans rechargement, et l'état survit au rechargement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR --- src/features/grid/components/heatmap-grid.tsx | 195 +++++++++++++++--- src/features/grid/store/use-grid-store.ts | 13 +- 2 files changed, 171 insertions(+), 37 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index c48bcb4..bb73b16 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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([ "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) => { 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 ( 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: () =>
CA
Réseau
, 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: () =>
Qté
Réseau
, 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: () =>
Magasins
/ 270
, 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: () =>
PV moyen
Réseau
, + size: 88, + cell: ({ row }: { row: { original: ProductRow } }) => { + const val = prixMoyenReseau(row.original); + return ( +
+ {val != null ? fmtEuro2(val) : "-"} +
+ ); + }, + }, + { + id: "prixVente", + accessorFn: (row: ProductRow) => valeurTriable(row.prixVente), + sortUndefined: "last" as const, + header: () =>
PV central
Article
, + size: 88, + cell: ({ row }: { row: { original: ProductRow } }) => { + const val = row.original.prixVente; + return ( +
+ {val != null ? fmtEuro2(val) : "-"} +
+ ); + }, + }, + ] : []), { id: "caParMagasinReseau", - accessorFn: (row) => getQlikNetworkSortValue(row.caParMagasinReseau, "caParMagasinReseau", sorting), + accessorFn: (row) => valeurTriable(row.caParMagasinReseau), + sortUndefined: "last" as const, header: () =>
CA / Mag
Réseau
, 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: () =>
Marge %
Réseau
, size: 75, cell: ({ row }) => { @@ -887,7 +967,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { size: 110, cell: ({ row }) => , }, - ], [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) {
{portalContainer && createPortal(
- {/* Repli du bloc mensuel : douze colonnes d'un coup, pas une par une. */} - + {/* + * « 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. + */} + + + + + + + Blocs de colonnes + + setShowMonthlySales(!!v)} + onSelect={(e: Event) => e.preventDefault()} + className="cursor-pointer" + > + + + + Ventes mensuelles + + 12 mois · détail au clic sur le total + + + + + setShowPrices(!!v)} + onSelect={(e: Event) => e.preventDefault()} + className="cursor-pointer" + > + + + + Prix + + PV moyen réseau · PV central + + + + + +