diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index b606af7..732e426 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ 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 } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Eye, EyeOff } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -24,6 +24,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { GammeSelect } from "@/features/grid/components/gamme-select"; import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; +import { TuileStat } from "@/features/grid/components/stat-tile"; +import { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal"; import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { @@ -245,36 +247,6 @@ interface CellDetailData { receptions: number | null; } -/** - * Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels. - * - * Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font - * paraître un nombre court anormalement lâche. Les chiffres tabulaires sont - * réservés aux colonnes qui s'alignent verticalement (tableau, axes). - */ -function TuileStat({ label, valeur, indice, couleur, icone: Icone }: { - label: string; - valeur: string; - indice?: string; - couleur?: string; - icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; -}) { - return ( -
-
- {label} -
-
- {Icone && } - - {valeur} - -
- {indice &&
{indice}
} -
- ); -} - /** * Carte « tendance réseau » : le verdict en tuiles, puis les trois séries * mensuelles en petits multiples. @@ -465,6 +437,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { const displayDensity = useGridStore((s) => s.displayDensity); const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES); const activeMagasin = useGridStore((s) => s.activeMagasin); + const showMonthlySales = useGridStore((s) => s.showMonthlySales); + const setShowMonthlySales = useGridStore((s) => s.setShowMonthlySales); // Filtre client-side par code3 (famille) et codeGamme const filteredData = useMemo(() => { @@ -500,6 +474,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { // État pour le détail d'une cellule mensuelle (modal) const [cellDetail, setCellDetail] = useState(null); const [networkModal, setNetworkModal] = useState(null); + // Détail 12 mois du produit, ouvert depuis la case de total + const [monthlyModal, setMonthlyModal] = useState(null); const tableContainerRef = useRef(null); // Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client @@ -777,7 +753,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { ); }, }, - ...MONTHS_12.map((monthKey) => ({ + // Bloc mensuel repliable : les colonnes sont retirées du modèle, pas + // seulement cachées — inutile de faire calculer douze cellules par ligne + // à TanStack pour ne rien peindre. Le détail reste accessible d'un clic + // sur la case de total. + ...(showMonthlySales ? MONTHS_12.map((monthKey) => ({ id: `month_${monthKey}`, header: () =>
{formatMonthLabel(monthKey)}
, size: 68, @@ -804,7 +784,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { /> ); }, - })), + })) : []), { id: "totalQuantite", accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0), @@ -814,14 +794,25 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { const qty = activeMagasin === "TOTAL" ? row.original.totalQuantite : (row.original.quantiteByStore?.[activeMagasin] ?? 0); + // Cliquable → détail 12 mois : ventes, entrées en stock, stock par + // magasin. `stopPropagation` sinon le clic sélectionne la ligne. return ( -
+
+ ); }, }, @@ -884,7 +875,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { size: 110, cell: ({ row }) => , }, - ], [MONTHS_12, activeMagasin, isAdmin, sorting]); // activeMagasin déclenche re-render des cellules mensuelles et totaux + ], [MONTHS_12, activeMagasin, isAdmin, sorting, showMonthlySales]); // activeMagasin déclenche re-render des cellules mensuelles et totaux const table = useReactTable({ data: filteredData, @@ -939,6 +930,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { <>
{portalContainer && createPortal( +
+ {/* Repli du bloc mensuel : douze colonnes d'un coup, pas une par une. */} +
, portalContainer )} @@ -1067,6 +1076,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { { if (!open) setNetworkModal(null); }}> {networkModal !== null && setNetworkModal(null)} />} + {/* Modal détail 12 mois (ventes / entrées / stock par magasin) */} + { if (!open) setMonthlyModal(null); }}> + {monthlyModal !== null && ( + setMonthlyModal(null)} + /> + )} + ); } diff --git a/src/features/grid/components/product-monthly-modal.tsx b/src/features/grid/components/product-monthly-modal.tsx new file mode 100644 index 0000000..9d5dcab --- /dev/null +++ b/src/features/grid/components/product-monthly-modal.tsx @@ -0,0 +1,352 @@ +"use client"; + +/** + * CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ». + * + * La colonne de total dit COMBIEN, jamais COMMENT. On empile donc les trois + * séries qui expliquent le total, dans l'ordre où on les lit : ce qui est sorti + * (ventes), ce qui est entré (réceptions), puis ce qui reste — magasin par + * magasin, parce qu'un stock de 40 réparti sur deux sites et un stock de 40 + * bloqué sur un seul ne se pilotent pas pareil. + * + * SVG fait main comme le reste des graphiques du dossier : aucune librairie. + */ + +import React from "react"; +import { ShoppingCart, PackageOpen, Warehouse } from "lucide-react"; +import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { TuileStat } from "@/features/grid/components/stat-tile"; +import { formatMonthLabel, formatDate, SITE_LABELS } from "@/features/grid/lib/months"; +import type { ProductRow } from "@/types/grid"; + +/** Couleurs de séries : slots de la palette catégorielle validée (cf. `globals.css`). */ +const COULEUR_VENTES = "var(--viz-1)"; +const COULEUR_ENTREES = "var(--viz-3)"; + +const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); + +/** + * Barres mensuelles sur une ligne de zéro explicite. + * + * Les quantités peuvent être NÉGATIVES (retours supérieurs aux ventes sur un + * mois) : l'échelle part donc du minimum réel et les barres se dessinent de part + * et d'autre du zéro, sinon un mois de retours sortirait du cadre par le bas. + * + * Chaque barre porte sa valeur écrite : douze nombres restent lisibles à cette + * largeur, et aucune valeur n'est ainsi accessible au seul survol. + */ +function BarresMensuelles({ mois, valeurs, couleur, aria }: { + mois: string[]; + valeurs: number[]; + couleur: string; + aria: string; +}) { + const n = valeurs.length; + const min = Math.min(0, ...valeurs); + const max = Math.max(0, ...valeurs); + + // Une barre négative écrit sa valeur SOUS elle : sans marge basse + // supplémentaire, ce nombre se poserait sur les libellés de mois. + const W = 660, hautPlot = 104, padH = 18; + const padB = min < 0 ? 42 : 24; + const H = padH + hautPlot + padB; + + const etendue = max - min || 1; + const y = (v: number) => padH + hautPlot - ((v - min) / etendue) * hautPlot; + const yZero = y(0); + + const pas = W / n; + const largeur = Math.min(pas * 0.6, 34); + + return ( +
+ + + {valeurs.map((v, i) => { + const cx = pas * i + pas / 2; + const yVal = y(v); + const haut = Math.min(yVal, yZero); + const hauteur = Math.max(Math.abs(yVal - yZero), 1.5); + return ( + + {v !== 0 && ( + + )} + + {fmt(v)} + + + {formatMonthLabel(mois[i])} + + + ); + })} + +
+ ); +} + +/** En-tête de section : repère coloré + titre + cumul de la série. */ +function EnteteSection({ icone: Icone, titre, detail, couleur }: { + icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; + titre: string; + detail: string; + couleur: string; +}) { + return ( +
+ + +

{titre}

+ {detail} +
+ ); +} + +/** + * Stock de fin de mois, un magasin par ligne. + * + * La ligne de total est recalculée depuis les lignes affichées : un total qui ne + * correspond pas à la somme visible fait douter du tableau entier. + */ +function TableauStockMagasins({ row, mois, activeMagasin }: { + row: ProductRow; + mois: string[]; + activeMagasin: string; +}) { + const sites = [...new Set([ + ...Object.keys(row.stock12mByStore ?? {}), + ...Object.keys(row.sales12mByStore ?? {}), + ])].sort(); + + // Sans ventilation par site (produit jamais mouvementé, données partielles), + // on montre au moins la série agrégée plutôt qu'un tableau vide. + const lignes = sites.length > 0 + ? sites.map((site) => ({ + cle: site, + nom: SITE_LABELS[site]?.nom ?? site, + actif: activeMagasin === site, + derniereEntree: row.derniereLivraisonByStore?.[site], + valeurs: mois.map((m) => row.stock12mByStore?.[site]?.[m] ?? 0), + })) + : [{ + cle: "TOTAL", + nom: "Total réseau", + actif: true, + derniereEntree: row.derniereLivraison, + valeurs: mois.map((m) => row.stock12m[m] ?? 0), + }]; + + const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0)); + + return ( +
+ + + + + {mois.map((m) => ( + + ))} + + + + {lignes.map((l) => { + const surligne = l.actif && activeMagasin !== "TOTAL"; + return ( + + + {l.valeurs.map((v, i) => ( + + ))} + + ); + })} + {lignes.length > 1 && ( + + + {totaux.map((v, i) => ( + + ))} + + )} + +
+ Magasin + + {formatMonthLabel(m)} +
+
{l.nom}
+ {/* La date de dernière entrée explique un stock qui ne bouge + plus : elle est écrite, pas réservée au survol. */} +
+ {l.derniereEntree ? `dern. entrée ${formatDate(l.derniereEntree)}` : "aucune entrée"} +
+
+ {fmt(v)} +
+ Total + + {fmt(v)} +
+
+ ); +} + +/** + * Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin. + * + * Les deux premières séries suivent le magasin actif de la Grille (cohérence + * avec la case cliquée) ; la troisième reste toujours ventilée, c'est son objet. + */ +export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: { + row: ProductRow; + mois: string[]; + activeMagasin: string; + onClose: () => void; +}) { + const surTotal = activeMagasin === "TOTAL"; + const libelleMagasin = surTotal ? "Tous magasins" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin); + + const ventes = mois.map((m) => (surTotal + ? row.sales12m[m] + : row.sales12mByStore?.[activeMagasin]?.[m]) ?? 0); + const entrees = mois.map((m) => (surTotal + ? row.receptions12m[m] + : row.receptions12mByStore?.[activeMagasin]?.[m]) ?? 0); + const stocks = mois.map((m) => (surTotal + ? row.stock12m[m] + : row.stock12mByStore?.[activeMagasin]?.[m]) ?? 0); + + // Sur un magasin donné, la dernière entrée du réseau n'est pas la sienne. + const derniereEntree = surTotal + ? row.derniereLivraison + : row.derniereLivraisonByStore?.[activeMagasin]; + + const totalVentes = ventes.reduce((t, v) => t + v, 0); + const totalEntrees = entrees.reduce((t, v) => t + v, 0); + const stockFin = stocks[stocks.length - 1] ?? 0; + const moisAvecVente = ventes.filter((v) => v !== 0).length; + + // Couverture : combien de mois le stock actuel tient-il au rythme des 12 + // derniers mois ? Sans vente, le ratio n'existe pas — on ne l'invente pas. + const rythme = totalVentes / mois.length; + const couverture = rythme > 0 ? stockFin / rythme : null; + + return ( + + + + {row.libelle1} + +

+ {row.codein} + {row.reference ? ` · ${row.reference}` : ""} + {" · "} + {libelleMagasin} + {" · 12 mois glissants, mois en cours exclu"} +

+
+ +
+ + 0 ? `+${fmt(totalEntrees)}` : "—"} + indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"} + couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined} + icone={PackageOpen} + /> + +
+ +
+ + +
+ +
+ 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"} + couleur={COULEUR_ENTREES} + /> + +
+ +
+ + +
+
+ ); +} diff --git a/src/features/grid/components/stat-tile.tsx b/src/features/grid/components/stat-tile.tsx new file mode 100644 index 0000000..596d6ff --- /dev/null +++ b/src/features/grid/components/stat-tile.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; + +/** + * Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels. + * + * Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font + * paraître un nombre court anormalement lâche. Les chiffres tabulaires sont + * réservés aux colonnes qui s'alignent verticalement (tableau, axes). + * + * Extraite de `heatmap-grid.tsx` pour être partagée entre la modale de tendance + * réseau et la modale de détail mensuel produit. Aucun changement de rendu. + */ +export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: { + label: string; + valeur: string; + indice?: string; + couleur?: string; + icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; +}) { + return ( +
+
+ {label} +
+
+ {Icone && } + + {valeur} + +
+ {indice &&
{indice}
} +
+ ); +} diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 82977d4..77ee9e9 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -20,6 +20,16 @@ interface GridState { columnVisibility: Record; /** Persisted column sizing state */ columnSizing: Record; + /** + * Bloc des 12 colonnes mensuelles affiché ou non dans la Grille. + * + * Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas + * une manipulation, et l'état « bloc replié » doit survivre au renouvellement + * de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque + * mois, un drapeau ne change pas. + */ + showMonthlySales: boolean; + setShowMonthlySales: (visible: boolean) => void; /** Currently displayed store: "TOTAL" | "292" | "579" */ activeMagasin: string; @@ -75,7 +85,7 @@ function computeSummary(rows: ProductRow[], drafts: Record, m /** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */ type GridPersisted = Pick< GridState, - "filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" + "filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" >; export const useGridStore = create()( @@ -106,6 +116,9 @@ export const useGridStore = create()( activeGridQuery: "", columnVisibility: {}, columnSizing: {}, + showMonthlySales: true, + + setShowMonthlySales: (visible) => set({ showMonthlySales: visible }), setActiveMagasin: (code) => { set({ activeMagasin: code }); @@ -239,6 +252,10 @@ export const useGridStore = create()( 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 + // pas, et une valeur `undefined` écraserait la valeur par défaut + // à la fusion (le bloc mensuel disparaîtrait sans raison). + if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true; return etat; }, // Only persist filters, display density, drafts, active grid query, and column visibility/sizing @@ -249,6 +266,7 @@ export const useGridStore = create()( activeGridQuery: state.activeGridQuery, columnVisibility: state.columnVisibility, columnSizing: state.columnSizing, + showMonthlySales: state.showMonthlySales, }), } )