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

Grille : détail mensuel au clic sur le total, bloc mensuel repliable
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-13 13:00:24 +02:00
commit 1f4571bd7a
4 files changed
+468 -42

No files matched your search

+61 -41
View File
@@ -13,7 +13,7 @@ import {
RowSelectionState, RowSelectionState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@@ -24,6 +24,8 @@ import { useGridStore } from "@/features/grid/store/use-grid-store";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { GammeSelect } from "@/features/grid/components/gamme-select"; import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell"; 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 type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -245,36 +247,6 @@ interface CellDetailData {
receptions: number | null; 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 (
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-1">
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
/** /**
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries * Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
* mensuelles en petits multiples. * mensuelles en petits multiples.
@@ -465,6 +437,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const displayDensity = useGridStore((s) => s.displayDensity); const displayDensity = useGridStore((s) => s.displayDensity);
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES); const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
const activeMagasin = useGridStore((s) => s.activeMagasin); 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 // Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
@@ -500,6 +474,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
// État pour le détail d'une cellule mensuelle (modal) // État pour le détail d'une cellule mensuelle (modal)
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null); const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null); const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
// Détail 12 mois du produit, ouvert depuis la case de total
const [monthlyModal, setMonthlyModal] = useState<ProductRow | null>(null);
const tableContainerRef = useRef<HTMLDivElement>(null); const tableContainerRef = useRef<HTMLDivElement>(null);
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client // 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}`, id: `month_${monthKey}`,
header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>, header: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68, size: 68,
@@ -804,7 +784,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
/> />
); );
}, },
})), })) : []),
{ {
id: "totalQuantite", id: "totalQuantite",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0), 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" const qty = activeMagasin === "TOTAL"
? row.original.totalQuantite ? row.original.totalQuantite
: (row.original.quantiteByStore?.[activeMagasin] ?? 0); : (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 ( return (
<div className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border" style={{ <button
background: "var(--bg-elevated)", type="button"
color: "var(--text-primary)", onClick={(e) => {
borderColor: "var(--border-strong)" e.stopPropagation();
}}> setMonthlyModal(row.original);
}}
title="Voir le détail des 12 mois : ventes mensuelles, entrées en stock et stock par magasin"
className="mx-auto w-[60px] text-center px-1 tabular-nums text-sm font-black py-1.5 rounded-md border cursor-pointer transition-all hover:brightness-110 active:scale-95"
style={{
background: "var(--bg-elevated)",
color: "var(--text-primary)",
borderColor: "var(--border-strong)"
}}
>
{Math.round(qty).toLocaleString("fr-FR")} {Math.round(qty).toLocaleString("fr-FR")}
</div> </button>
); );
}, },
}, },
@@ -884,7 +875,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
size: 110, size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />, cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
}, },
], [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({ const table = useReactTable({
data: filteredData, data: filteredData,
@@ -939,6 +930,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<> <>
<div className="h-full w-full relative"> <div className="h-full w-full relative">
{portalContainer && createPortal( {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>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@@ -971,7 +979,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
); );
})} })}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu>, </DropdownMenu>
</div>,
portalContainer portalContainer
)} )}
@@ -1067,6 +1076,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}> <Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />} {networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
</Dialog> </Dialog>
{/* Modal détail 12 mois (ventes / entrées / stock par magasin) */}
<Dialog open={monthlyModal !== null} onOpenChange={(open) => { if (!open) setMonthlyModal(null); }}>
{monthlyModal !== null && (
<ProductMonthlyModal
row={monthlyModal}
mois={MONTHS_12}
activeMagasin={activeMagasin}
onClose={() => setMonthlyModal(null)}
/>
)}
</Dialog>
</> </>
); );
} }
@@ -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 (
<div className="overflow-x-auto min-w-0">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
height={H}
preserveAspectRatio="xMidYMid meet"
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
// base et décale la section suivante.
style={{ minWidth: 520, display: "block" }}
role="img"
aria-label={aria}
>
<line x1={0} y1={yZero} x2={W} y2={yZero} stroke="var(--border)" strokeWidth={1} />
{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 (
<g key={mois[i]}>
{v !== 0 && (
<rect
x={cx - largeur / 2}
y={haut}
width={largeur}
height={hauteur}
rx={2.5}
fill={couleur}
opacity={0.9}
/>
)}
<text
x={cx}
y={v < 0 ? yZero + hauteur + 12 : haut - 5}
textAnchor="middle"
fontSize={11.5}
fontWeight={700}
fill={v === 0 ? "var(--text-muted)" : "var(--text-secondary)"}
>
{fmt(v)}
</text>
<text x={cx} y={H - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
{formatMonthLabel(mois[i])}
</text>
</g>
);
})}
</svg>
</div>
);
}
/** 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 (
<div className="flex items-center gap-2 flex-wrap">
<span className="rounded-sm shrink-0" style={{ width: 14, height: 4, background: couleur }} />
<Icone className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<h3 className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
<span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>
</div>
);
}
/**
* 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 (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
Magasin
</th>
{mois.map((m) => (
<th key={m} className="text-right px-1.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
{lignes.map((l) => {
const surligne = l.actif && activeMagasin !== "TOTAL";
return (
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
<div className="font-semibold">{l.nom}</div>
{/* La date de dernière entrée explique un stock qui ne bouge
plus : elle est écrite, pas réservée au survol. */}
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>
{l.derniereEntree ? `dern. entrée ${formatDate(l.derniereEntree)}` : "aucune entrée"}
</div>
</td>
{l.valeurs.map((v, i) => (
<td
key={mois[i]}
className="px-1.5 py-1.5 text-right tabular-nums"
style={{ color: v === 0 ? "var(--text-muted)" : "var(--text-primary)" }}
>
{fmt(v)}
</td>
))}
</tr>
);
})}
{lignes.length > 1 && (
<tr style={{ borderTop: "1px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
Total
</td>
{totaux.map((v, i) => (
<td key={mois[i]} className="px-1.5 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)" }}>
{fmt(v)}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
);
}
/**
* 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 (
<DialogContent
// `grid-cols-1` : sans colonne explicite, la grille de la modale se
// dimensionne sur son contenu le plus large (le tableau des magasins)
// et déborde par la droite au lieu de le laisser défiler.
className="max-w-[calc(100%-2rem)] sm:max-w-4xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{row.libelle1}
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
{row.codein}
{row.reference ? ` · ${row.reference}` : ""}
{" · "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleMagasin}</span>
{" · 12 mois glissants, mois en cours exclu"}
</p>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<TuileStat
label="Ventes 12 m"
valeur={fmt(totalVentes)}
indice={`${moisAvecVente} mois avec vente · ${rythme.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} / mois`}
icone={ShoppingCart}
/>
<TuileStat
label="Entrées 12 m"
valeur={totalEntrees > 0 ? `+${fmt(totalEntrees)}` : "—"}
indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"}
couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined}
icone={PackageOpen}
/>
<TuileStat
label="Stock fin de période"
valeur={fmt(stockFin)}
indice={couverture != null
? `≈ ${couverture.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} mois de couverture`
: "pas de vente sur la période"}
icone={Warehouse}
/>
</div>
<section className="space-y-1">
<EnteteSection
icone={ShoppingCart}
titre="Ventes mensuelles"
detail={`${fmt(totalVentes)} unités sur 12 mois`}
couleur={COULEUR_VENTES}
/>
<BarresMensuelles
mois={mois}
valeurs={ventes}
couleur={COULEUR_VENTES}
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
/>
</section>
<section className="space-y-1">
<EnteteSection
icone={PackageOpen}
titre="Entrées en stock"
detail={totalEntrees > 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"}
couleur={COULEUR_ENTREES}
/>
<BarresMensuelles
mois={mois}
valeurs={entrees}
couleur={COULEUR_ENTREES}
aria={`Entrées en stock de ${row.libelle1} sur 12 mois`}
/>
</section>
<section className="space-y-1.5">
<EnteteSection
icone={Warehouse}
titre="Stock par magasin"
detail="stock de fin de mois"
couleur="var(--border-strong)"
/>
<TableauStockMagasins row={row} mois={mois} activeMagasin={activeMagasin} />
</section>
</DialogContent>
);
}
@@ -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 (
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-1">
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
+19 -1
View File
@@ -20,6 +20,16 @@ interface GridState {
columnVisibility: Record<string, boolean>; columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */ /** Persisted column sizing state */
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
/**
* 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" */ /** Currently displayed store: "TOTAL" | "292" | "579" */
activeMagasin: string; activeMagasin: string;
@@ -75,7 +85,7 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */ /** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
type GridPersisted = Pick< type GridPersisted = Pick<
GridState, GridState,
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" "filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales"
>; >;
export const useGridStore = create<GridState>()( export const useGridStore = create<GridState>()(
@@ -106,6 +116,9 @@ export const useGridStore = create<GridState>()(
activeGridQuery: "", activeGridQuery: "",
columnVisibility: {}, columnVisibility: {},
columnSizing: {}, columnSizing: {},
showMonthlySales: true,
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
setActiveMagasin: (code) => { setActiveMagasin: (code) => {
set({ activeMagasin: code }); set({ activeMagasin: code });
@@ -239,6 +252,10 @@ export const useGridStore = create<GridState>()(
const c = etat.filters.code3; const c = etat.filters.code3;
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null; 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; return etat;
}, },
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing // Only persist filters, display density, drafts, active grid query, and column visibility/sizing
@@ -249,6 +266,7 @@ export const useGridStore = create<GridState>()(
activeGridQuery: state.activeGridQuery, activeGridQuery: state.activeGridQuery,
columnVisibility: state.columnVisibility, columnVisibility: state.columnVisibility,
columnSizing: state.columnSizing, columnSizing: state.columnSizing,
showMonthlySales: state.showMonthlySales,
}), }),
} }
) )