"use client"; import React, { useRef, useState, useCallback, useEffect, useMemo } from "react"; 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 } from "lucide-react"; 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)}`; } /** * Generates a deterministic color for a store name. */ function getStoreColor(name: string) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } // Spread hues more widely and use golden ratio for better distribution const goldenRatioConjugate = 0.618033988749895; let h = (Math.abs(hash) * goldenRatioConjugate * 360) % 360; // Ensure colors are vibrant but readable return { bg: `hsl(${h}, 75%, 45%)`, border: `hsl(${h}, 85%, 30%)`, text: "#fff" }; } interface HeatmapGridProps { onSelectionChange?: (codeins: string[]) => void; } export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const { rows, draftChanges, setDraftGamme, filters, displayDensity } = useGridStore(); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState({}); const [isMounted, setIsMounted] = useState(false); 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 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", 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: "libelle1", header: "Désignation", size: 280, cell: ({ row }) => (
{row.original.libelle1}
{row.original.workingStores.map((magasin) => { const colors = getStoreColor(magasin); return (
{magasin.charAt(0).toUpperCase()}
); })}
), }, { 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.codeGamme || "-"}
), }, { id: "gamme", header: () =>
Gamme
, size: 110, cell: ({ row }) => { const effectiveGamme = (draftChanges[row.original.codein] ?? row.original.codeGamme) as GammeCode | null; return ( setDraftGamme(row.original.codein, g)} /> ); }, }, { id: "ai", header: "Recommandation IA", size: 230, enableSorting: false, cell: ({ row }) => , }, ], [draftChanges, setDraftGamme, MONTHS_12]); const table = useReactTable({ data: rows, columns, state: { sorting, globalFilter: filters.search, rowSelection }, onSortingChange: setSorting, onRowSelectionChange: handleRowSelectionChange, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, }); const { rows: tableRows } = table.getRowModel(); const rowVirtualizer = useVirtualizer({ count: tableRows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => rowHeight, overscan: 20, }); const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0); if (!isMounted) { return (
Chargement de la grille...
); } return (
{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 ( ); })} ))} {rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = tableRows[virtualRow.index]; const effectiveGamme = draftChanges[row.original.codein] ?? row.original.codeGamme; const isSelected = row.getIsSelected(); return ( row.toggleSelected()} className={cn( "absolute w-full flex items-center cursor-pointer transition-colors", effectiveGamme === "Z" && "opacity-40" )} style={{ height: `${rowHeight}px`, transform: `translateY(${virtualRow.start}px)`, borderBottom: "1px solid var(--border)", background: isSelected ? "var(--accent-bg)" : undefined, borderLeft: isSelected ? "2px solid var(--accent)" : undefined, }} > {row.getVisibleCells().map((cell) => { 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(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && ( header.column.getIsSorted() === "asc" ? : header.column.getIsSorted() === "desc" ? : )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); }