fix(grid): aligner la fenêtre 12 mois sur les données et tabuler les ventes du modal

Le total « Tot. 12m » est figé côté serveur sur SA fenêtre de 12 mois, tandis
que la Grille et le modal recalculaient les 12 mois depuis l'horloge du
navigateur. Dès que les deux divergeaient (onglet ouvert au changement de
mois, lignes servies depuis le cache), un mois de ventes disparaissait des
colonnes tout en restant compté dans le total : 28 sur la ligne, 20 dans le
modal.

- months.ts : `getMonthsFromRows()` lit la fenêtre dans les clés des séries
  reçues ; `getLast12Months()` ne sert plus que de repli avant chargement.
- heatmap-grid : MONTHS_12 dérivé des lignes chargées.
- product-monthly-modal : le tableau par magasin devient générique et sert
  aussi aux ventes, sous le graphique, avec une colonne de cumul « 12 m »
  qui retombe sur le total de la Grille.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177HLQz6w2hgoXpx6t1Rkn9
This commit is contained in:
Claude committed 2026-09-02 10:43:26 +00:00
1 parent 2404b22628
commit 585e171708
3 files changed
+121 -40

No files matched your search

@@ -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<HTMLDivElement>(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);
@@ -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<string, Record<string, number>> | undefined;
/** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */
agrege: Record<string, number>;
/** 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) => (
<td
key={key}
className={`px-1.5 py-1.5 text-right tabular-nums${gras ? " font-bold" : ""}`}
style={{ color: v === 0 && !gras ? "var(--text-muted)" : "var(--text-primary)" }}
>
{fmt(v)}
</td>
);
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 }}>
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
@@ -177,6 +194,11 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
{formatMonthLabel(m)}
</th>
))}
{cumul && (
<th className="text-right px-2 py-1.5 font-bold whitespace-nowrap" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
12 m
</th>
)}
</tr>
</thead>
<tbody>
@@ -186,21 +208,16 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
<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>
{/* L'information secondaire (dernière entrée, mois avec
vente) est écrite, pas réservée au survol. */}
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</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)}
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(l.valeurs))}
</td>
))}
)}
</tr>
);
})}
@@ -209,11 +226,12 @@ function TableauStockMagasins({ row, mois, activeMagasin }: {
<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)}
{totaux.map((v, i) => cellule(v, mois[i], true))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(totaux))}
</td>
))}
)}
</tr>
)}
</tbody>
@@ -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 }: {
/>
</div>
<section className="space-y-1">
<section className="space-y-1.5">
<EnteteSection
icone={ShoppingCart}
titre="Ventes mensuelles"
@@ -321,6 +344,22 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
couleur={COULEUR_VENTES}
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
/>
{/* 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. */}
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.sales12mByStore}
agrege={row.sales12m}
sousLigne={(_site, valeurs) => {
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`}
/>
</section>
<section className="space-y-1">
@@ -345,7 +384,19 @@ export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
detail="stock de fin de mois"
couleur="var(--border-strong)"
/>
<TableauStockMagasins row={row} mois={mois} activeMagasin={activeMagasin} />
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.stock12mByStore}
agrege={row.stock12m}
sousLigne={(site) => {
// 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`}
/>
</section>
</DialogContent>
);
+26
View File
@@ -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, number> }>): 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);