Files
CollectFlow/src/features/grid/components/heatmap-grid.tsx
T
Claude af5933b5a7 Lot 6 : Grille plus légère, Commandes allégées et nettoyage
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:48:43 +00:00

1543 lines
78 KiB
TypeScript

"use client";
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
RowSelectionState,
type FilterFn,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, 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";
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 { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import {
getLast12Months,
getMonthsFromRows,
formatMonthLabel,
formatDate,
SITE_LABELS,
getStoreConfig,
} from "@/features/grid/lib/months";
import {
computeNetworkTrend,
computeStoresSeries,
trendLabel,
TREND_COLOR,
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
import { Button } from "@/components/ui/button";
interface HeatmapGridProps {
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
codeFournisseur: string;
onSelectionChange?: (codeins: string[]) => void;
isAdmin?: boolean;
/** Sert à nommer et documenter les extractions issues de la Grille. */
nomFournisseur?: string;
}
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
/*
* Valeurs dérivées calculées une fois par ligne. Une ligne reçue ne change plus
* (une modification produit un nouvel objet) : on peut les garder tant qu'elle
* existe, sans rien invalider. Avant, la tendance était recalculée à chaque
* rendu de chaque case, et le texte de recherche à chaque frappe.
*/
const tendances = new WeakMap<ProductRow, NetworkTrend>();
function tendanceDe(row: ProductRow): NetworkTrend {
let t = tendances.get(row);
if (!t) {
t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
tendances.set(row, t);
}
return t;
}
const textesRecherche = new WeakMap<ProductRow, string>();
function texteRecherche(row: ProductRow): string {
let t = textesRecherche.get(row);
if (t === undefined) {
// Saut de ligne entre les champs : une recherche ne peut pas en contenir,
// donc aucune correspondance « à cheval » sur deux champs.
t = [row.libelle1, row.codein, row.reference, row.gtin].map((v) => String(v ?? "")).join("\n").toLowerCase();
textesRecherche.set(row, t);
}
return t;
}
/** Recherche sur libellé, code, référence et code-barres. */
const filtreRecherche: FilterFn<ProductRow> = (row, _columnId, valeur: string) => texteRecherche(row.original).includes(valeur);
// Saisie mise en minuscules une seule fois par frappe, pas une fois par ligne.
filtreRecherche.resolveFilterValue = (valeur) => String(valeur ?? "").toLowerCase();
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"caReseau",
"qteReseau",
"nbMagasinsReseau",
"tendanceReseau",
"prixMoyenReseau",
"caParMagasinReseau",
"margePctReseau",
]);
/**
* 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;
/**
* 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 = fmtEur2;
/**
* Prix de vente à montrer pour le magasin consulté.
*
* `cube_pv` a une clé `(artnoid, site)`, mais notre base n'y met en pratique
* qu'une ligne par article : le prix n'est pas ventilé par magasin. La colonne
* doit donc rester lisible dans les deux cas de figure —
*
* · magasin consulté tarifé → SON prix ;
* · prix unique, non ventilé → ce prix vaut pour tous les magasins ;
* · plusieurs prix mais aucun pour ce magasin → rien, plutôt que celui du
* voisin ;
* · en « tous magasins » : le prix commun, et s'ils divergent le plus élevé
* assorti d'un signal. Une cellule ne peut afficher qu'un nombre, mais
* taire l'écart ferait passer le prix d'un magasin pour celui des deux.
*/
function prixVenteAffiche(row: ProductRow, activeMagasin: string): { valeur: number | null; divergent: boolean; detail: string | null } {
const entrees = Object.entries(row.prixVenteByStore ?? {});
// Aucun prix ventilé : reste le PV de la fiche article, s'il existe.
if (entrees.length === 0) return { valeur: row.prixVente ?? null, divergent: false, detail: null };
// Comparaison au centime : deux flottants issus du même prix peuvent différer
// dans les décimales lointaines sans que le prix affiché change.
const distincts = new Set(entrees.map(([, pv]) => Math.round(pv * 100)));
if (activeMagasin !== "TOTAL") {
const pv = row.prixVenteByStore?.[activeMagasin];
if (pv != null) return { valeur: pv, divergent: false, detail: null };
if (distincts.size === 1) {
return { valeur: entrees[0][1], divergent: false, detail: "prix unique, non ventilé par magasin" };
}
return { valeur: null, divergent: false, detail: null };
}
const detail = entrees.length > 1
? entrees
.sort(([a], [b]) => a.localeCompare(b))
.map(([site, pv]) => `${SITE_LABELS[site]?.nom ?? site} : ${fmtEuro2(pv)}`)
.join(" · ")
: null;
return {
valeur: Math.max(...entrees.map(([, pv]) => pv)),
divergent: distincts.size > 1,
detail,
};
}
// =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// =========================================================================
// 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes)
/**
* Cellule dont la valeur se copie d'un clic.
*
* Extraite de la colonne « Code interne », qui la portait en ligne avec un
* `eslint-disable react-hooks/rules-of-hooks` : un `useState` dans le corps d'un
* `cell()` de TanStack n'est pas rendu au même endroit d'un rendu à l'autre. Un
* vrai composant supprime la dérogation et rend la copie réutilisable — les
* références et les gencodes se recopient autant que les codes internes.
*
* `stopPropagation` est indispensable : sans lui, le clic sélectionne aussi la
* ligne.
*/
const CopiableCell = React.memo(function CopiableCell({ value, titre, className, style }: {
value: string | null | undefined;
titre: string;
className?: string;
style?: React.CSSProperties;
}) {
const [copied, setCopied] = useState(false);
const texte = (value ?? "").trim();
if (!texte) {
return <span className={className} style={style}>-</span>;
}
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(texte);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
onClick={handleCopy}
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors min-w-0"
title={copied ? "Copié !" : titre}
>
<span className={cn("truncate", className)} style={style}>{texte}</span>
<span className="shrink-0 transition-all duration-200">
{copied ? (
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
) : (
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
)}
</span>
</div>
);
});
const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => {
// Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change
const codein = row.codein;
const isDraft = useGridStore((s) => s.draftChanges[codein] !== undefined);
const effectiveGamme = useGridStore((s) => s.draftChanges[codein] ?? row.codeGamme);
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
const isModified = row.codeGamme !== row.codeGammeInit && row.codeGammeInit !== null;
const displayValue = (!effectiveGamme || effectiveGamme.trim() === "") ? "Aucune" : effectiveGamme;
if (!isAdmin) {
const gamme = trouverGamme(displayValue);
return (
<div
className={cn("w-full rounded-lg border py-1.5 px-2 text-center text-xs font-bold", gamme ? gamme.classes : CLASSES_SANS_GAMME)}
title={gamme ? `${gamme.code} — ${gamme.nom} : ${gamme.description}` : "Aucune gamme"}
>
{gamme ? gamme.code : "—"}
</div>
);
}
return (
<div className="relative group/gamme">
<GammeSelect
value={effectiveGamme as GammeCode | null}
isDraft={isDraft}
onChange={(g: GammeCode) => setDraftGamme(codein, g)}
/>
{isModified && !isDraft && (
<div className="absolute -right-2 -top-2 bg-emerald-500 text-white rounded-full p-0.5 shadow-sm z-10 animate-in zoom-in-50" title="Modifié et validé">
<Check className="w-2.5 h-2.5" />
</div>
)}
</div>
);
});
GammeCell.displayName = "GammeCell";
/**
* En-tête du tableau, mémoïsé : sans cela, chaque événement de défilement
* redessinait la trentaine d'en-têtes (tri, redimensionnement…). Il ne change
* qu'avec les colonnes, le tri ou les largeurs.
*/
const GridHeader = React.memo(function GridHeader({ table }: {
table: Table<ProductRow>;
// Les props suivantes ne sont pas lues directement : elles servent à la
// comparaison de React.memo (l'en-tête lit ces états via `table`).
columnsKey: string;
sorting: SortingState;
columnSizing: Record<string, number>;
/** Case « tout sélectionner ». */
rowSelection: RowSelectionState;
data: ProductRow[];
}) {
return (
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{(() => {
const figesEntete = decalagesFiges(
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
);
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
return headerGroup.headers.map((header) => {
const gaucheEntete = figesEntete.get(header.column.id);
const enteteFigee = gaucheEntete !== undefined;
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
const size = header.getSize();
return (
<th
key={header.id}
className={cn(
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
!enteteFigee && "hover:bg-white/5",
)}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-secondary)",
justifyContent: isCenter ? "center" : "flex-start",
...(enteteFigee ? {
position: "sticky" as const,
left: gaucheEntete,
zIndex: 2,
// Le fond du <thead> est un dégradé translucide : sans
// fond propre, les autres intitulés défileraient à
// travers l'en-tête figé. On reprend le même dégradé
// pour que la bande reste d'un seul tenant.
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderRight: header.column.id === dernierFigeEntete
? "1px solid var(--border-strong)"
: undefined,
} : {}),
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<div className="shrink-0 opacity-40">
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
: <ChevronsUpDown className="w-3 h-3" />
}
</div>
)}
</div>
{/* Handle de redimensionnement de la colonne */}
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => e.stopPropagation()}
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
/>
)}
</th>
);
});
})()}
</tr>
))}
</thead>
);
});
/**
* Libellés du menu « Colonnes ». Les en-têtes de colonnes sont des fonctions
* (deux lignes, infobulles) : le menu affichait leur identifiant technique
* (« caReseau », « gammeInitial »…).
*/
const LIBELLES_COLONNES: Record<string, string> = {
codein: "Code article",
libelle1: "Désignation",
reference: "Référence",
gtin: "Code-barres",
libelle3: "Famille",
caReseau: "CA réseau",
qteReseau: "Quantité réseau",
nbMagasinsReseau: "Magasins vendeurs (réseau)",
tendanceReseau: "Tendance réseau (12 mois)",
prixMoyenReseau: "Prix moyen réseau",
prixVente: "Prix de vente de nos magasins",
caParMagasinReseau: "CA par magasin (réseau)",
margePctReseau: "Marge % réseau",
totalQuantite: "Total 12 mois",
totalCa: "Chiffre d'affaires",
totalMarge: "Marge",
gammeInitial: "Gamme actuelle (FF)",
gamme: "Gamme",
};
function libelleColonne(id: string, header: unknown): string {
if (LIBELLES_COLONNES[id]) return LIBELLES_COLONNES[id];
if (id.startsWith("month_")) return `Ventes ${formatMonthLabel(id.replace("month_", ""))}`;
return typeof header === "string" ? header : id;
}
import { Cell, Column, Row, type Table } from "@tanstack/react-table";
import { VirtualItem } from "@tanstack/react-virtual";
// 2. Composant isolé pour la Ligne Virtuelle
// (Gère l'état grisé "Z" localement sans faire re-render toute la grid)
interface VirtualRowProps {
virtualRow: VirtualItem;
row: Row<ProductRow>;
rowHeight: number;
isSelected: boolean;
/** Identifiants des colonnes visibles, concaténés — cf. le corps du composant. */
columnsKey: string;
columnSizing: Record<string, number>;
}
/**
* Colonnes figées à gauche : celles qui disent QUELLE ligne on lit.
*
* Sans elles, dès qu'on défile vers les totaux ou les mois, la Désignation
* sort de l'écran et plus rien n'identifie la ligne — sur 2600 px de tableau
* pour ~1150 px visibles, c'est la moitié du temps.
*
* `libelle1` a été rapprochée de `codein` dans l'ordre des colonnes : le
* figeage exige des colonnes contiguës depuis la gauche, et geler en plus
* Référence et GTIN aurait immobilisé 636 px au lieu de 406.
*/
const COLONNES_FIGEES = new Set(["select", "codein", "libelle1"]);
/**
* Décalage `left` de chaque colonne figée, par cumul des largeurs.
*
* On s'arrête à la première colonne non figée : une colonne figée qui ne
* toucherait pas le bord flotterait au milieu du tableau. Si l'utilisateur
* masque « Code interne », le calcul se réajuste tout seul.
*/
function decalagesFiges(colonnes: { id: string; taille: number }[]): Map<string, number> {
const out = new Map<string, number>();
let cumul = 0;
for (const c of colonnes) {
if (!COLONNES_FIGEES.has(c.id)) break;
out.set(c.id, cumul);
cumul += c.taille;
}
return out;
}
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey, columnSizing }: VirtualRowProps) => {
// Uniquement la ligne concernée écoute son propre changement pour l'effet visuel
const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme);
// ⚠️ Ces deux props ne sont pas lues : elles n'existent que pour rompre la
// mémoïsation. TanStack met ses objets `Row` en cache sur la seule identité
// des DONNÉES ; changer le jeu de colonnes ne leur donne aucune référence
// neuve. Sans un signal supplémentaire, `React.memo` laisserait les lignes
// déjà montées peindre l'ancien jeu de cellules pendant que l'en-tête, lui,
// affiche le nouveau — seules les lignes fraîchement virtualisées seraient à
// jour, et la grille se retrouverait à deux vitesses.
//
// La signature couvre TOUT changement de colonnes (case décochée dans le menu
// « Colonnes », bloc mensuel replié) là où l'ancien objet `columnVisibility`
// ne voyait que le menu.
void columnsKey;
void columnSizing; // idem, pour le redimensionnement des colonnes
const cellules = row.getVisibleCells();
const figes = decalagesFiges(cellules.map((c) => ({ id: c.column.id, taille: c.column.getSize() })));
const dernierFige = [...figes.keys()].at(-1);
return (
<tr
data-index={virtualRow.index}
onClick={() => row.toggleSelected()}
className={cn(
// Pas de `transition-all` : la ligne est positionnée par `transform`
// et chaque défilement l'aurait animée pendant 200 ms.
"absolute top-0 left-0 w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
)}
style={{
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: "1px solid var(--border)",
background: isSelected ? "var(--accent-bg)" : "transparent",
borderLeft: isSelected ? "3px solid var(--accent)" : "3px solid transparent",
}}
>
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
const gauche = figes.get(cell.column.id);
const estFige = gauche !== undefined;
const derniereFigee = estFige && cell.column.id === dernierFige;
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 === "prixVente" || cell.column.id === "gamme";
const size = cell.column.getSize();
return (
<td
key={cell.id}
className={cn(
"px-2 overflow-hidden flex items-center transition-colors",
// Une cellule figée doit être OPAQUE : le reste de la ligne
// glisse dessous. Le survol passe donc par une teinte pleine,
// pas par le voile `bg-white/5` des cellules ordinaires.
estFige
? (isSelected
// Teinte de sélection rendue OPAQUE (mélangée au fond) : la
// variable --accent-bg est translucide, et les cellules qui
// défilent dessous apparaissaient à travers.
? "bg-[color-mix(in_srgb,var(--accent)_12%,var(--bg-surface))]"
: "bg-[var(--bg-surface)] group-hover/row:bg-[var(--bg-elevated)]")
: cn("group-hover/row:bg-white/5", isSelected && "bg-transparent"),
)}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
height: `${rowHeight}px`,
justifyContent: isCenter ? "center" : "flex-start",
...(estFige ? {
position: "sticky" as const,
left: gauche,
// Au-dessus des cellules voisines, sous l'en-tête (z-10).
zIndex: 2,
// Trait de séparation : sans lui, rien ne dit où finit la
// zone figée et où commence ce qui défile.
borderRight: derniereFigee ? "1px solid var(--border-strong)" : undefined,
} : {}),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
});
GridRow.displayName = "GridRow";
// =========================================================================
// Composant modal pour le détail d'une cellule mensuelle
// =========================================================================
interface CellDetailData {
row: ProductRow;
monthKey: string;
qty: number | null;
stock: number | null;
receptions: number | null;
}
/**
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
* mensuelles en petits multiples.
*
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
* jamais par la couleur seule. Les courbes, elles, portent des teintes
* d'identité — sans quoi la même couleur voudrait dire deux choses.
*/
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = tendanceDe(row);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
const dernier = values.length - 1;
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
const verdict = trend.nouveau
? "Nouveau"
: pct != null
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
: "—";
return (
<DialogContent className="max-w-2xl" 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)" }}>
Réseau · 12 mois glissants, mois en cours exclu
</p>
</DialogHeader>
{!trend.hasData ? (
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</div>
) : (
<>
<div className="flex flex-wrap gap-2 mt-1">
<TuileStat
label="Tendance"
valeur={verdict}
indice={trend.surQteParMagasin
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
couleur={color}
icone={Fleche}
/>
{trend.perStore && (
<TuileStat
label="Qté / magasin"
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
/>
)}
<TuileStat
label="Magasins vendeurs"
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
/>
</div>
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
</>
)}
</DialogContent>
);
}
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
const hasReceptions = (d.receptions ?? 0) > 0;
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
return (
<DialogContent className="max-w-sm" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader>
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{d.row.libelle1}
</DialogTitle>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(d.monthKey)}{" · "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{storeLabel}</span>
</p>
</DialogHeader>
<div className="grid grid-cols-3 gap-3 mt-1">
{/* Ventes */}
<div className="flex flex-col items-center gap-1.5 rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<ShoppingCart className="w-4 h-4 opacity-60" style={{ color: "var(--text-muted)" }} />
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
{d.qty != null ? Math.round(d.qty).toLocaleString("fr-FR") : "—"}
</span>
<span className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Ventes</span>
</div>
{/* Entrées */}
<div
className="flex flex-col items-center gap-1.5 rounded-xl p-3"
style={{
background: hasReceptions ? "rgba(16,185,129,0.08)" : "var(--bg-elevated)",
border: `1px solid ${hasReceptions ? "rgba(16,185,129,0.25)" : "var(--border)"}`,
}}
>
<PackageOpen className="w-4 h-4" style={{ color: hasReceptions ? "rgb(16,185,129)" : "var(--text-muted)" }} />
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: hasReceptions ? "rgb(16,185,129)" : "var(--text-primary)" }}>
{hasReceptions ? `+${Math.round(d.receptions!).toLocaleString("fr-FR")}` : "—"}
</span>
<span className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Entrées</span>
</div>
{/* Stock fin de mois */}
<div className="flex flex-col items-center gap-1.5 rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<Warehouse className="w-4 h-4 opacity-60" style={{ color: "var(--text-muted)" }} />
<span className="text-[22px] font-black tabular-nums leading-none" style={{ color: "var(--text-primary)" }}>
{d.stock != null ? Math.round(d.stock).toLocaleString("fr-FR") : "—"}
</span>
<span className="text-xs font-medium text-center" style={{ color: "var(--text-muted)" }}>Stock fin de mois</span>
</div>
</div>
{/*
* En « tous magasins », les trois tuiles ci-dessus sont des cumuls.
* Le détail par magasin est justement ce qu'on vient vérifier ici :
* savoir que le stock est de 40 ne dit pas s'il est réparti ou
* entièrement sur un seul site.
*/}
{activeMagasin === "TOTAL" && <VentilationParMagasin d={d} />}
</DialogContent>
);
}
/** Détail ventes / entrées / stock du mois, magasin par magasin. */
function VentilationParMagasin({ d }: { d: CellDetailData }) {
const sites = [...new Set([
...Object.keys(d.row.sales12mByStore ?? {}),
...Object.keys(d.row.stock12mByStore ?? {}),
])].sort();
if (sites.length === 0) return null;
return (
<div className="mt-3 rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-xs">
<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>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Ventes</th>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Entrées</th>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Stock</th>
</tr>
</thead>
<tbody>
{sites.map((site) => {
const ventes = d.row.sales12mByStore?.[site]?.[d.monthKey] ?? 0;
const entrees = d.row.receptions12mByStore?.[site]?.[d.monthKey] ?? 0;
const stock = d.row.stock12mByStore?.[site]?.[d.monthKey] ?? 0;
return (
<tr key={site} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[site]?.nom ?? site}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{Math.round(ventes).toLocaleString("fr-FR")}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: entrees > 0 ? "rgb(16,185,129)" : "var(--text-muted)" }}>
{entrees > 0 ? `+${Math.round(entrees).toLocaleString("fr-FR")}` : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
{Math.round(stock).toLocaleString("fr-FR")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// =========================================================================
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
const rows = useGridStore((s) => s.rows);
const code3 = useGridStore((s) => s.filters.code3);
const codeGamme = useGridStore((s) => s.filters.codeGamme);
const search = useGridStore((s) => s.filters.search);
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);
const showPrices = useGridStore((s) => s.showPrices);
const setShowPrices = useGridStore((s) => s.setShowPrices);
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
void activeMagasin;
// Garde : un état persisté d'une version antérieure peut encore contenir
// une chaîne. La migration du store le corrige, mais un `new Set("320211")`
// produirait un ensemble de caractères et viderait la Grille sans rien dire.
const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null;
if (!codes && !codeGamme) return rows.slice();
// Set plutôt que includes() : la liste peut compter des dizaines de
// nomenclatures, et le filtre est réévalué pour chaque ligne.
const nomenclatures = codes ? new Set(codes) : null;
return rows.filter(r => {
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
if (codeGamme) {
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
const norm = g.trim() === "" ? "Aucune" : g;
if (norm !== codeGamme) return false;
}
return true;
});
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const columnVisibility = useGridStore((s) => s.columnVisibility);
const setColumnVisibility = useGridStore((s) => s.setColumnVisibility);
const columnSizing = useGridStore((s) => s.columnSizing);
const setColumnSizing = useGridStore((s) => s.setColumnSizing);
const [isMounted, setIsMounted] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
// État pour le détail d'une cellule mensuelle (modal)
const [cellDetail, setCellDetail] = useState<CellDetailData | 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);
// Trous d'assortiment : ce qu'un magasin ne travaille pas, en haut du classement affiché
const [gapsOpen, setGapsOpen] = useState(false);
const tableContainerRef = useRef<HTMLDivElement>(null);
// 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.
// Mémorisé sur sa clé texte : un nouveau tableau à chaque arrivée de lignes
// reconstruisait toutes les définitions de colonnes (donc tout le tableau).
const monthsKey = useMemo(() => (getMonthsFromRows(rows) ?? getLast12Months()).join(","), [rows]);
const MONTHS_12 = useMemo(() => monthsKey.split(","), [monthsKey]);
useEffect(() => {
setIsMounted(true);
const container = document.getElementById('grid-toolbar-actions');
setPortalContainer(container);
}, []);
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
const handleRowSelectionChange = useCallback(
(updater: React.SetStateAction<RowSelectionState>) => {
setRowSelection(updater);
},
[]
);
// Sélection remise à zéro au changement de fournisseur seulement : elle est
// indexée par code article, et survit donc à un rechargement des lignes, à un
// enregistrement ou au complément des chiffres d'un magasin.
useEffect(() => {
setRowSelection({});
}, [codeFournisseur]);
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
{
id: "select",
size: 36,
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
ref={(el) => { if (el) el.indeterminate = table.getIsSomeRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
className="accent-emerald-500 cursor-pointer"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onClick={(e) => e.stopPropagation()}
className="accent-emerald-500 cursor-pointer"
/>
),
},
{
accessorKey: "codein",
header: "Code article",
size: 90,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier le code interne"
className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100"
style={{ color: "var(--text-muted)" }}
/>
),
},
{
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ row }) => {
const r = row.original;
const isDifferentSupplier = r.fournisseurPrincipalCode && r.fournisseurPrincipalCode !== r.codeFournisseur;
const warnTitle = isDifferentSupplier
? `Dernier fournisseur enregistré : ${r.fournisseurPrincipalNom ?? r.fournisseurPrincipalCode} (${r.fournisseurPrincipalCode}) — les ventes sont à attribuer à ce fournisseur`
: undefined;
return (
<div className="flex items-center gap-2 overflow-hidden w-full pr-1">
{isDifferentSupplier && (
<span title={warnTitle} className="shrink-0 flex items-center">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
</span>
)}
<span className="text-[13px] font-bold truncate flex-1" title={r.libelle1} style={{ color: "var(--text-primary)" }}>
{r.libelle1}
</span>
<div className="flex gap-1.5 shrink-0">
{r.workingStores.map((magasin) => {
const config = getStoreConfig(magasin);
const lastLivDate = r.derniereLivraisonByStore?.[magasin];
const storeName = SITE_LABELS[magasin]?.nom ?? magasin;
const tooltipTitle = `Magasin : ${storeName}\n${
lastLivDate
? `Dernière entrée en stock : ${formatDate(lastLivDate)}`
: "Aucune entrée enregistrée"
}`;
return (
<div
key={magasin}
title={tooltipTitle}
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-xs font-bold border shadow-sm"
style={{
background: config.bg,
borderColor: config.border,
color: config.text,
}}
>
<Store className="w-2.5 h-2.5" strokeWidth={3} />
<span>{config.label}</span>
</div>
);
})}
</div>
</div>
);
},
},
{
accessorKey: "reference",
header: "Référence",
size: 110,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier la référence"
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "gtin",
header: "Code-barres",
size: 120,
cell: ({ getValue }) => (
<CopiableCell
value={getValue<string>()}
titre="Copier l'EAN / GTIN"
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
style={{ color: "var(--text-secondary)" }}
/>
),
},
{
accessorKey: "libelle3",
header: "Famille",
size: 200,
cell: ({ row }) => {
const code = row.original.code3;
const label = row.original.libelle3;
const display = code && label ? `${code} — ${label}` : (label || code || "");
return (
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }} title={display}>
{display}
</span>
);
},
},
{
id: "caReseau",
accessorFn: (row) => valeurTriable(row.caReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title="Chiffre d'affaires du produit dans les magasins du réseau, sur 12 mois">CA<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 90,
cell: ({ row }) => {
const val = row.original.caReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
{val != null ? fmtEur0(val) : "-"}
</div>
);
},
},
{
id: "qteReseau",
accessorFn: (row) => valeurTriable(row.qteReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title="Quantités vendues dans les magasins du réseau, sur 12 mois">Qté<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 80,
cell: ({ row }) => {
const val = row.original.qteReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? fmtEntier(val) : "-"}
</div>
);
},
},
{
id: "nbMagasinsReseau",
accessorFn: (row) => valeurTriable(row.nbMagasinsReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title={`Nombre de magasins du réseau (sur ${NB_MAGASINS_RESEAU}) qui vendent ce produit`}>Magasins<br/><span className="text-xs font-normal opacity-75">sur {NB_MAGASINS_RESEAU}</span></div>,
size: 80,
cell: ({ row }) => {
const val = row.original.nbMagasinsReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? val : "-"}
</div>
);
},
},
{
id: "tendanceReseau",
accessorFn: (row) => {
const t = tendanceDe(row);
if (!t.hasData) return Number.NEGATIVE_INFINITY;
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
// forte progression possible : il doit remonter en tête du tri, pas
// tomber au fond avec les lignes sans données.
if (t.nouveau) return Number.POSITIVE_INFINITY;
return t.pct ?? Number.NEGATIVE_INFINITY;
},
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 78,
cell: ({ row }) => {
const trend = tendanceDe(row.original);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
<button
type="button"
onClick={() => setNetworkModal(row.original)}
title="Voir les ventes réseau mois par mois (12 derniers mois)"
className="w-full cursor-pointer rounded hover:bg-[var(--bg-elevated)] transition-colors py-0.5"
>
<TrendSparkline trend={trend} />
</button>
);
},
},
// 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: () => <div className="text-center w-full" title="Prix de vente moyen constaté dans le réseau">Prix moyen<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const val = prixMoyenReseau(row.original);
return (
<div
className="text-center tabular-nums text-[12px] font-bold"
style={{ color: "var(--text-secondary)" }}
title={val != null
? `Prix de vente moyen constaté sur le réseau : CA réseau ÷ quantité vendue (12 mois glissants)`
: "Pas de quantité réseau sur la période : le prix moyen n'a pas de sens"}
>
{val != null ? fmtEuro2(val) : "-"}
</div>
);
},
},
{
id: "prixVente",
accessorFn: (row: ProductRow) => valeurTriable(prixVenteAffiche(row, activeMagasin).valeur),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title="Prix de vente pratiqué dans nos magasins (≠ : prix différents selon le magasin)">Prix vente<br/><span className="text-xs font-normal opacity-75">magasin</span></div>,
size: 88,
cell: ({ row }: { row: { original: ProductRow } }) => {
const { valeur, divergent, detail } = prixVenteAffiche(row.original, activeMagasin);
return (
<div
className="text-center tabular-nums text-[12px] font-bold flex items-center justify-center gap-0.5"
style={{ color: "var(--text-primary)" }}
title={valeur != null
? `Prix de vente en base (cube_pv)${detail ? ` — ${detail}` : ""}`
: "Aucun prix de vente en base pour cet article"}
>
{valeur != null ? fmtEuro2(valeur) : "-"}
{/* Prix différents d'un magasin à l'autre : le signaler, la
cellule ne peut en afficher qu'un. */}
{divergent && <span className="text-xs font-bold" style={{ color: "var(--accent-warning)" }}>≠</span>}
</div>
);
},
},
] : []),
{
id: "caParMagasinReseau",
accessorFn: (row) => valeurTriable(row.caParMagasinReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title="Chiffre d'affaires moyen d'un magasin du réseau qui vend ce produit">CA / magasin<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 85,
cell: ({ row }) => {
const val = row.original.caParMagasinReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? fmtEur0(val) : "-"}
</div>
);
},
},
{
id: "margePctReseau",
accessorFn: (row) => valeurTriable(row.margePctReseau),
sortUndefined: "last" as const,
header: () => <div className="text-center w-full" title="Taux de marge du produit dans le réseau">Marge %<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 75,
cell: ({ row }) => {
const val = row.original.margePctReseau;
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const pct = Math.abs(val) <= 1 ? val * 100 : val;
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
{fmtDecimal1(pct)}%
</div>
);
},
},
// 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: () => <div className="text-center w-full">{formatMonthLabel(monthKey)}</div>,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => {
const qty = activeMagasin === "TOTAL"
? (row.original.sales12m[monthKey] ?? null)
: (row.original.sales12mByStore?.[activeMagasin]?.[monthKey] ?? null);
const stock = activeMagasin === "TOTAL"
? (row.original.stock12m[monthKey] ?? null)
: (row.original.stock12mByStore?.[activeMagasin]?.[monthKey] ?? null);
const receptions = activeMagasin === "TOTAL"
? (row.original.receptions12m[monthKey] ?? null)
: (row.original.receptions12mByStore?.[activeMagasin]?.[monthKey] ?? null);
return (
<HeatmapCell
value={qty}
tooltipStock={stock}
tooltipReceptions={receptions}
onClick={(e) => {
e.stopPropagation();
setCellDetail({ row: row.original, monthKey, qty, stock, receptions });
}}
/>
);
},
})) : []),
{
id: "totalQuantite",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalQuantite : (row.quantiteByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Quantités vendues sur les 12 derniers mois — cliquer pour le détail mois par mois">Total 12 m</div>,
size: 90,
cell: ({ row }: { row: { original: ProductRow } }) => {
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 (
<button
type="button"
onClick={(e) => {
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)"
}}
>
{fmtEntier(qty)}
</button>
);
},
},
{
id: "totalCa",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalCa : (row.caByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Chiffre d'affaires sur 12 mois">CA</div>,
size: 90,
cell: ({ row }: { row: { original: ProductRow } }) => {
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
return (
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(ca)}&nbsp;€
</div>
);
},
},
{
id: "totalMarge",
accessorFn: (row: ProductRow) => activeMagasin === "TOTAL" ? row.totalMarge : (row.margeByStore?.[activeMagasin] ?? 0),
header: () => <div className="text-center w-full" title="Marge en euros et taux de marge sur 12 mois">Marge</div>,
size: 110,
cell: ({ row }: { row: { original: ProductRow } }) => {
const marge = activeMagasin === "TOTAL"
? row.original.totalMarge
: (row.original.margeByStore?.[activeMagasin] ?? 0);
const ca = activeMagasin === "TOTAL"
? row.original.totalCa
: (row.original.caByStore?.[activeMagasin] ?? 0);
const taux = ca > 0 ? (marge / ca) * 100 : 0;
return (
<div className="flex flex-col items-center justify-center">
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
{fmtEntier(marge)}&nbsp;€
</span>
<span className="tabular-nums text-xs font-semibold" style={{ color: couleurMarge(taux) }}>
{taux.toFixed(1)}%
</span>
</div>
);
},
},
{
id: "gammeInitial",
header: () => <div className="text-center w-full print:hidden" title="Gamme enregistrée aujourd'hui dans FF, avant vos modifications">Actuelle</div>,
size: 56,
cell: ({ row }) => (
<div className="text-center font-bold text-[12px] print:hidden" style={{ color: "var(--text-secondary)" }}>
{row.original.codeGammeInit || "-"}
</div>
),
},
{
id: "gamme",
header: () => <div className="text-center w-full" title="Gamme choisie : A Cœur, B Complémentaire, C Saisonnier, Y En veille, Z Sortie">Gamme</div>,
size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
},
// `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,
columns,
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
columnResizeMode: "onChange",
// Identifiant stable : avec l'index par défaut, la sélection désignait
// d'autres produits dès qu'un filtre changeait l'ordre des lignes.
getRowId: (row) => row.codein,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
// La recherche ne dépend pas de la colonne (elle lit libellé, code, référence
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
// colonne filtrable (une quinzaine) à chaque frappe.
getColumnCanGlobalFilter: (column) => column.id === "codein",
globalFilterFn: filtreRecherche,
});
const { rows: tableRows } = table.getRowModel();
const prevSelectionRef = useRef<string[]>([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
// La sélection est indexée par code article (`getRowId`), et seules les
// lignes encore affichées comptent : une action groupée ne doit jamais
// toucher un produit masqué par un filtre ou par la recherche.
const selectedCodeins = table
.getFilteredSelectedRowModel()
.rows.map((r) => r.original.codein);
// Only update if selection actually changed to avoid re-render loops & console warnings
const currentString = JSON.stringify(selectedCodeins);
const prevString = JSON.stringify(prevSelectionRef.current);
if (currentString !== prevString) {
prevSelectionRef.current = selectedCodeins;
// Delay update to next tick to ensure we're out of any render cycles
setTimeout(() => onSelectionChange(selectedCodeins), 0);
}
}, [table, rowSelection, onSelectionChange, filteredData, search]);
const rowVirtualizer = useVirtualizer({
// Pas de rendu synchrone à chaque événement de défilement (flushSync) :
// React regroupe les mises à jour, le défilement reste fluide.
useFlushSync: false,
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
overscan: 8,
});
const visibleColumns = table.getVisibleLeafColumns();
// `getSize()` et non `columnDef.size` : le redimensionnement de colonnes est
// actif et persisté. En lisant la taille déclarée, la largeur plancher de la
// table restait figée sur l'ancienne somme dès qu'on élargissait une colonne,
// ce qui rognait les dernières et raccourcissait la course de défilement.
const totalWidth = visibleColumns.reduce((s, c) => s + c.getSize(), 0);
// Signature du jeu de colonnes affiché : elle sert de signal de re-rendu aux
// 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);
/**
* Le haut du classement TEL QU'IL EST AFFICHÉ, pour les trous d'assortiment.
*
* Les lignes viennent du modèle trié et filtré de TanStack : le tri en cours
* — CA, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a servi
* à classer est relue par `getValue` sur la colonne triée. Tronqué à la
* profondeur maximale offerte : au-delà, on copierait 130 000 lignes pour en
* regarder 500. Calculé seulement quand la fenêtre est ouverte.
*/
const classementAffiche: LigneClassee[] = useMemo(() => {
if (!gapsOpen) return [];
const critereId = sorting[0]?.id ?? null;
return tableRows.slice(0, PROFONDEUR_MAX).map((r) => ({
row: r.original,
valeurCritere: critereId ? r.getValue(critereId) : null,
}));
}, [gapsOpen, tableRows, sorting]);
if (!isMounted) {
return (
<div
className="h-full w-full overflow-auto rounded-[12px] flex items-center justify-center"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
}}
>
<div className="text-sm text-[var(--text-secondary)]">Préparation de la grille…</div>
</div>
);
}
return (
<>
<div className="h-full w-full relative">
{portalContainer && createPortal(
<div className="flex items-center gap-2">
{/* Trous d'assortiment — lit le classement affiché, tri compris. */}
<Button
variant="outline"
size="sm"
onClick={() => setGapsOpen(true)}
disabled={tableRows.length === 0}
title="Les produits du haut du classement affiché qu'un de nos magasins ne vend pas et n'a pas en stock"
>
<PackageX />
<span className="hidden lg:inline">Non travaillés</span>
</Button>
{/*
* « Blocs de colonnes » 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.
*/}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
title="Afficher ou masquer les ventes mois par mois et les prix"
aria-label="Afficher ou masquer les blocs de colonnes"
>
{nbVues > 0 ? <Eye /> : <EyeOff />}
<span className="hidden lg:inline">Affichage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] z-50">
<DropdownMenuLabel className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Blocs de colonnes affichés
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showMonthlySales}
onCheckedChange={(v) => setShowMonthlySales(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-sm font-medium">Ventes mois par mois</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Les 12 derniers mois
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showPrices}
onCheckedChange={(v) => setShowPrices(!!v)}
onSelect={(e: Event) => e.preventDefault()}
className="cursor-pointer"
>
<span className="flex items-center gap-2">
<Tag className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<span className="flex flex-col">
<span className="text-sm font-medium">Prix</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Prix moyen du réseau et prix de vente de nos magasins
</span>
</span>
</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Choisir les colonnes affichées">
<SlidersHorizontal />
<span className="hidden lg:inline">Colonnes</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[260px] max-h-[60vh] overflow-y-auto z-50">
<DropdownMenuLabel className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Colonnes affichées
</DropdownMenuLabel>
{table
.getAllLeafColumns()
.filter((column: Column<ProductRow, unknown>) => column.getCanHide() && column.id !== "select")
.map((column: Column<ProductRow, unknown>) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="text-sm cursor-pointer"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e: Event) => e.preventDefault()}
>
{libelleColonne(column.id, column.columnDef.header)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>,
portalContainer
)}
<div
ref={tableContainerRef}
// `tabIndex` : sans lui, les flèches, Origine et Fin ne défilent pas
// la grille. Il ne restait que Maj+molette, que personne ne devine.
tabIndex={0}
aria-label="Tableau des articles — défilement horizontal sur 12 mois"
className="h-full w-full overflow-auto rounded-[14px] relative grid-scroll"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
boxShadow: "var(--shadow-md)"
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<GridHeader table={table} columnsKey={columnsKey} sorting={sorting} columnSizing={columnSizing} rowSelection={rowSelection} data={filteredData} />
{/*
* La clé porte AUSSI la signature des colonnes : changer de jeu de
* colonnes remonte le corps du tableau au lieu de compter sur la
* comparaison de props de `React.memo`. Une ligne mémoïsée qui rate
* le signal continue de peindre l'ancien jeu de cellules, et la
* grille se retrouve à deux vitesses — en-tête à jour, corps en
* retard. Un remontage ne coûte que les ~30 lignes virtualisées, et
* le redimensionnement (continu) ne le déclenche pas : les
* identifiants de colonnes ne changent pas.
*/}
<tbody key={`${displayDensity}|${columnsKey}`} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const isSelected = row.getIsSelected();
return (
// Pas de <div> autour de la ligne : un <div> dans un <tbody>
// est du HTML invalide (erreur d'hydratation signalée par React).
<GridRow
key={row.id}
virtualRow={virtualRow}
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
columnsKey={columnsKey}
columnSizing={columnSizing}
/>
);
})}
</tbody>
</table>
</div>
</div>
{/* Modal détail cellule mensuelle */}
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
</Dialog>
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
</Dialog>
{/* Trous d'assortiment sur le haut du classement affiché */}
<Dialog open={gapsOpen} onOpenChange={setGapsOpen}>
{gapsOpen && (
<AssortmentGapsModal
classement={classementAffiche}
critereId={sorting[0]?.id ?? null}
mois={MONTHS_12}
magasinInitial={activeMagasin}
nomFournisseur={nomFournisseur}
onClose={() => setGapsOpen(false)}
/>
)}
</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>
</>
);
}
/**
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
* parent change d'état (sélection, progression du chargement…).
*/
export const HeatmapGrid = React.memo(HeatmapGridInner);