diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index ffa249b..281eedb 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -32,6 +32,7 @@ import type { ProductRow, GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { getLast12Months, + getMonthsFromRows, formatMonthLabel, formatDate, SITE_LABELS, @@ -620,8 +621,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat const [gapsOpen, setGapsOpen] = useState(false); const tableContainerRef = useRef(null); - // Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client - const MONTHS_12 = useMemo(() => getLast12Months(), []); + // Les 12 mois viennent des données, pas de l'horloge du navigateur : les + // totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être + // aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts). + // Le calcul local ne sert que tant qu'aucune ligne n'est chargée. + const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]); useEffect(() => { setIsMounted(true); diff --git a/src/features/grid/components/product-monthly-modal.tsx b/src/features/grid/components/product-monthly-modal.tsx index 9d5dcab..d4488ec 100644 --- a/src/features/grid/components/product-monthly-modal.tsx +++ b/src/features/grid/components/product-monthly-modal.tsx @@ -129,15 +129,27 @@ function EnteteSection({ icone: Icone, titre, detail, couleur }: { } /** - * Stock de fin de mois, un magasin par ligne. + * Une série mensuelle ventilée par magasin, un magasin par ligne. + * + * Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock + * (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul). * * 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 }: { +function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: { row: ProductRow; mois: string[]; activeMagasin: string; + /** Série par magasin (site → mois → valeur). */ + parSite: Record> | undefined; + /** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */ + agrege: Record; + /** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */ + sousLigne: (site: string | null, valeurs: number[]) => string; + /** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */ + cumul?: boolean; + aria: string; }) { const sites = [...new Set([ ...Object.keys(row.stock12mByStore ?? {}), @@ -147,26 +159,31 @@ function TableauStockMagasins({ row, mois, activeMagasin }: { // 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), - }]; + ? sites.map((site) => { + const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0); + return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) }; + }) + : [(() => { + const valeurs = mois.map((m) => agrege[m] ?? 0); + return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) }; + })()]; const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0)); + const somme = (v: number[]) => v.reduce((t, x) => t + x, 0); + + const cellule = (v: number, key: string, gras = false) => ( + + {fmt(v)} + + ); return (
- +
))} + {cumul && ( + + )} @@ -186,21 +208,16 @@ function TableauStockMagasins({ row, mois, activeMagasin }: { - {l.valeurs.map((v, i) => ( - - ))} + )} ); })} @@ -209,11 +226,12 @@ function TableauStockMagasins({ row, mois, activeMagasin }: { - {totaux.map((v, i) => ( - - ))} + )} )} @@ -225,8 +243,13 @@ function TableauStockMagasins({ row, mois, activeMagasin }: { /** * 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. + * Les graphiques suivent le magasin actif de la Grille (cohérence avec la case + * cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par + * magasin, c'est leur objet. + * + * `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) : + * c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la + * Grille. */ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: { row: ProductRow; @@ -308,7 +331,7 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: { /> -
+
+ {/* Le graphique donne la forme, le tableau donne les chiffres par + magasin — et son cumul « 12 m » est le même total que la tuile + et que la case cliquée dans la Grille. */} + { + const n = valeurs.filter((v) => v !== 0).length; + return n === 0 ? "aucune vente" : `${n} mois avec vente`; + }} + cumul + aria={`Ventes mensuelles de ${row.libelle1} par magasin`} + />
@@ -345,7 +384,19 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: { detail="stock de fin de mois" couleur="var(--border-strong)" /> - + { + // La date de dernière entrée explique un stock qui ne bouge plus. + const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison; + return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée"; + }} + aria={`Stock de fin de mois de ${row.libelle1} par magasin`} + />
); diff --git a/src/features/grid/lib/months.ts b/src/features/grid/lib/months.ts index 753ff47..87e4185 100644 --- a/src/features/grid/lib/months.ts +++ b/src/features/grid/lib/months.ts @@ -30,6 +30,32 @@ export function getLast12Months(): string[] { return months; } +/** + * Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des + * séries mensuelles des lignes reçues. + * + * `getLast12Months()` recalcule la fenêtre depuis l'horloge du navigateur ; or + * les totaux (`totalQuantite`, `quantiteByStore`…) sont figés côté serveur, sur + * SA fenêtre. Dès que les deux divergent — onglet resté ouvert au changement de + * mois, lignes servies depuis le cache, horloges décalées — un mois de ventes + * disparaît des colonnes tout en restant compté dans le total : la case + * « Tot. 12m » affiche 28 quand les douze cases visibles ne font que 20. + * Les clés des données sont la seule référence commune : on les prend. + * + * Renvoie `null` si aucune ligne ne porte de série (fournisseur sans vente, + * chargement en cours) — l'appelant retombe alors sur `getLast12Months()`. + */ +export function getMonthsFromRows(rows: ReadonlyArray<{ sales12m?: Record }>): string[] | null { + for (const r of rows) { + const keys = Object.keys(r.sales12m ?? {}).filter((k) => /^\d{6}$/.test(k)); + if (keys.length < 12) continue; + // Toutes les lignes d'un même calcul partagent la même fenêtre : la + // première ligne complète suffit. + return keys.sort().slice(-12); + } + return null; +} + /** `"202601"` → `"Jan 26"`. */ export function formatMonthLabel(key: string): string { const m = parseInt(key.slice(4, 6), 10);
@@ -177,6 +194,11 @@ function TableauStockMagasins({ row, mois, activeMagasin }: { {formatMonthLabel(m)} + 12 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"} -
+ {/* L'information secondaire (dernière entrée, mois avec + vente) est écrite, pas réservée au survol. */} +
{l.sous}
- {fmt(v)} + {l.valeurs.map((v, i) => cellule(v, mois[i]))} + {cumul && ( + + {fmt(somme(l.valeurs))}
Total - {fmt(v)} + {totaux.map((v, i) => cellule(v, mois[i], true))} + {cumul && ( + + {fmt(somme(totaux))}