"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,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
/**
* Returns premium styling and color for stores based on the store name's hash.
* This guarantees a consistent color for a given store, regardless of its position in the list.
*/
function getStoreConfig(name: string) {
const words = name.trim().split(/\s+/);
let label = words[0].charAt(0).toUpperCase();
if (words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
}
// =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// =========================================================================
// 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes)
const GammeCell = React.memo(({ row }: { row: ProductRow }) => {
// 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;
return (
setDraftGamme(codein, g)}
/>
{isModified && !isDraft && (
)}
);
});
GammeCell.displayName = "GammeCell";
import { Row } 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;
rowHeight: number;
isSelected: boolean;
columnVisibility: Record;
}
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility }: 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);
void columnVisibility; // Force re-render via React.memo when visibility changes
return (
row.toggleSelected()}
className={cn(
"absolute w-full flex items-center cursor-pointer transition-all 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: any) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || 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" || cell.column.id === "score" || cell.column.id === "gamme";
const size = cell.column.getSize();
return (
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
);
})}
);
});
GridRow.displayName = "GridRow";
// =========================================================================
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const { rows, filters, displayDensity } = useGridStore();
const [sorting, setSorting] = useState([]);
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState({});
const [isMounted, setIsMounted] = useState(false);
const [portalContainer, setPortalContainer] = useState(null);
const tableContainerRef = useRef(null);
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
const MONTHS_12 = useMemo(() => getLast12Months(), []);
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) => {
setRowSelection(updater);
},
[]
);
// Reset selection when rows change (e.g. supplier change)
useEffect(() => {
setRowSelection({});
}, [rows]);
const prevSelectionRef = useRef([]);
// Propagate selection changes via useEffect to avoid "update during render" error
useEffect(() => {
if (!onSelectionChange) return;
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
const selectedCodeins = selectedIdxs
.map((idx) => rows[parseInt(idx)]?.codein ?? "")
.filter(Boolean);
// 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);
}
}, [rowSelection, onSelectionChange, rows]);
const columns = React.useMemo[]>(() => [
{
id: "select",
size: 36,
header: ({ table }) => (
{ if (el) el.indeterminate = table.getIsSomeRowsSelected(); }}
onChange={table.getToggleAllRowsSelectedHandler()}
className="accent-emerald-500 cursor-pointer"
/>
),
cell: ({ row }) => (
e.stopPropagation()}
className="accent-emerald-500 cursor-pointer"
/>
),
},
{
accessorKey: "codein",
header: "Code interne",
size: 90,
cell: ({ getValue }) => {
const value = getValue();
// eslint-disable-next-line react-hooks/rules-of-hooks
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
{value}
{copied ? (
) : (
)}
);
},
},
{
accessorKey: "reference",
header: "Référence",
size: 110,
cell: ({ getValue }) => {
const val = getValue();
return (
{val || "-"}
);
},
},
{
accessorKey: "gtin",
header: "EAN / GTIN",
size: 120,
cell: ({ getValue }) => {
const val = getValue();
return (
{val || "-"}
);
},
},
{
accessorKey: "libelle1",
header: "Désignation",
size: 280,
cell: ({ row }) => (
{row.original.libelle1}
{row.original.workingStores.map((magasin) => {
const config = getStoreConfig(magasin);
return (
{config.label}
);
})}
),
},
{
accessorKey: "libelle3",
header: "Famille",
size: 160,
cell: ({ getValue }) => (
{getValue()}
),
},
{
accessorKey: "score",
header: "Score",
size: 60,
cell: ({ getValue }) => {
const val = getValue();
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
return (
{val}
);
},
},
...MONTHS_12.map((monthKey) => ({
id: `month_${monthKey}`,
header: () => {formatMonthLabel(monthKey)}
,
size: 68,
enableSorting: false,
cell: ({ row }: { row: { original: ProductRow } }) => (
),
})),
{
accessorKey: "totalQuantite",
header: () => Tot. 12m
,
size: 90,
cell: ({ getValue }) => (
{Math.round(getValue()).toLocaleString("fr-FR")}
),
},
{
accessorKey: "totalCa",
header: () => CA
,
size: 90,
cell: ({ getValue }) => (
{Math.round(getValue()).toLocaleString("fr-FR")} €
),
},
{
accessorKey: "totalMarge",
header: () => Marge
,
size: 110,
cell: ({ row }) => (
{Math.round(row.original.totalMarge).toLocaleString("fr-FR")} €
= 40 ? "var(--accent-success)" : row.original.tauxMarge >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
}}>
{row.original.tauxMarge.toFixed(1)}%
),
},
{
id: "gammeInitial",
header: () => Init.
,
size: 56,
cell: ({ row }) => (
{row.original.codeGammeInit || "-"}
),
},
{
id: "gamme",
header: () => Gamme
,
size: 110,
cell: ({ row }) => ,
},
{
id: "ai",
header: () => Recommandation IA
,
size: 230,
enableSorting: false,
cell: ({ row }) => (
),
},
], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !
const table = useReactTable({
data: rows,
columns,
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
globalFilterFn: (row, columnId, filterValue) => {
const search = String(filterValue).toLowerCase();
const libelle = String(row.original.libelle1 || "").toLowerCase();
const code = String(row.original.codein || "").toLowerCase();
const reference = String(row.original.reference || "").toLowerCase();
const gtin = String(row.original.gtin || "").toLowerCase();
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
},
});
const { rows: tableRows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const visibleColumns = table.getVisibleLeafColumns();
const totalWidth = visibleColumns.reduce((s, c) => s + ((c.columnDef as { size?: number }).size ?? 150), 0);
if (!isMounted) {
return (
Chargement de la grille...
);
}
return (
{portalContainer && createPortal(
{table
.getAllLeafColumns()
.filter((column: any) => column.getCanHide())
.map((column: any) => {
return (
column.toggleVisibility(!!value)}
onSelect={(e: Event) => e.preventDefault()}
>
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: column.id.startsWith('month_')
? formatMonthLabel(column.id.replace('month_', ''))
: column.id}
);
})}
,
portalContainer
)}
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || 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" || header.column.id === "score" || header.column.id === "gamme";
const size = header.getSize();
return (
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
{header.column.getIsSorted() === "asc" ?
: header.column.getIsSorted() === "desc" ?
:
}
)}
|
);
})}
))}
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const isSelected = row.getIsSelected();
return (
);
})}
);
}