Files
CollectFlow/src/features/grid/components/heatmap-grid.tsx
T
Claude 3519597fb7 Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il
ouvre une modale qui regroupe les produits affichés par nomenclature (code3)
et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos
magasins, le CA réseau, le poids de la nomenclature chez nous et dans le
réseau (écart en points) et l'indice de chaque magasin face au magasin moyen
du réseau. Tri par colonne, filtre texte, ligne de total et export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvNrtCGyjYgPHFoiUkJzhM
2026-10-05 10:12:49 +00:00

1507 lines
76 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, CalendarRange, Tag, Eye, EyeOff, Layers } 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 { ProductMonthlyModal } from "@/features/grid/components/product-monthly-modal";
import { NetworkMonthlyModal } from "@/features/grid/components/network-monthly-modal";
import { AssortmentGapsModal, PROFONDEUR_MAX, type LigneClassee } from "@/features/grid/components/assortment-gaps-modal";
import { NomenclatureCaModal } from "@/features/grid/components/nomenclature-ca-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,
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, 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;
}
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);
// CA par nomenclature et par magasin, face au réseau, sur les lignes affichées
const [nomenclaturesOpen, setNomenclaturesOpen] = 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 ? fmtEur2(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 ? fmtEur2(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: 105,
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)" }}>
{fmtEur2(ca)}
</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: 115,
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)" }}>
{fmtEur2(marge)}
</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]);
/** Les produits affichés (filtres et recherche compris), pour le CA par nomenclature. */
const lignesAffichees: ProductRow[] = useMemo(
() => (nomenclaturesOpen ? tableRows.map((r) => r.original) : []),
[nomenclaturesOpen, tableRows],
);
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">
{/* CA par nomenclature et par magasin, comparé au réseau. */}
<Button
variant="outline"
size="sm"
onClick={() => setNomenclaturesOpen(true)}
disabled={tableRows.length === 0}
title="Le CA de chaque nomenclature sur la période, par magasin, comparé au CA réseau"
>
<Layers />
<span className="hidden lg:inline">Nomenclatures</span>
</Button>
{/* 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}
trend={tendanceDe(networkModal)}
mois={MONTHS_12}
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>
{/* CA par nomenclature : nos magasins face au réseau */}
<Dialog open={nomenclaturesOpen} onOpenChange={setNomenclaturesOpen}>
{nomenclaturesOpen && (
<NomenclatureCaModal
rows={lignesAffichees}
mois={MONTHS_12}
nomFournisseur={nomFournisseur}
onClose={() => setNomenclaturesOpen(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);