From 4de420cd4895abbc58b95c0ec0678eb03b889d1d Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 5 Mar 2026 15:56:06 +0100 Subject: [PATCH] feat: implement `HeatmapGrid` component using TanStack Table and Virtual for efficient product data visualization with state management and performance optimizations. --- .../grid/components/export-dropdown.tsx | 114 ++++++++++++++++++ src/features/grid/components/grid-client.tsx | 56 +++++---- src/features/grid/components/heatmap-grid.tsx | 73 ++++++----- 3 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 src/features/grid/components/export-dropdown.tsx diff --git a/src/features/grid/components/export-dropdown.tsx b/src/features/grid/components/export-dropdown.tsx new file mode 100644 index 0000000..2771595 --- /dev/null +++ b/src/features/grid/components/export-dropdown.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { Download, FileSpreadsheet, FileText, ChevronDown } from "lucide-react"; +import * as ExcelJS from "exceljs"; +import { useGridStore } from "@/features/grid/store/use-grid-store"; + +export function ExportDropdown() { + const [isOpen, setIsOpen] = useState(false); + const { rows, draftChanges } = useGridStore(); + + const handleExportExcel = async () => { + setIsOpen(false); + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Gamme A"); + + worksheet.columns = [ + { header: "Magasin(s)", key: "magasins", width: 25 }, + { header: "Gencode", key: "gtin", width: 15 }, + { header: "Référence", key: "reference", width: 20 }, + { header: "Libellé", key: "libelle", width: 45 }, + { header: "Gamme", key: "gamme", width: 10 }, + ]; + + // Format Header + worksheet.getRow(1).font = { bold: true }; + + // Filter for effective Gamme A + const gammeARows = rows.filter((row) => { + const effectiveGamme = draftChanges[row.codein] ?? row.codeGamme; + return effectiveGamme === "A"; + }); + + gammeARows.forEach((row) => { + worksheet.addRow({ + magasins: row.workingStores.join(", "), + gtin: row.gtin || row.codein, // Fallback if no true gtin + reference: row.reference || "", + libelle: row.libelle1 || "", + gamme: draftChanges[row.codein] ?? row.codeGamme, + }); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = `Export_Gamme_A_${new Date().toISOString().split("T")[0]}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handlePrintPDF = () => { + setIsOpen(false); + window.print(); + }; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+ + +
+ + +
+
+ + )} +
+ ); +} diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index ab5e4ca..609149a 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -5,6 +5,7 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid"; import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar"; import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar"; import { GridFilterBar } from "@/features/grid/components/grid-filter-bar"; +import { ExportDropdown } from "@/features/grid/components/export-dropdown"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import type { ProductRow } from "@/types/grid"; @@ -89,37 +90,40 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins {" "}• {initialRows.length} références

- {hasDrafts && ( - - )} +
+ + {hasDrafts && ( + + )} +
{/* Filters */} -
+
{/* Bulk toolbar (contextual) */} -
+
setSelectedCodeins([])} @@ -134,7 +138,9 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
{/* Summary bar */} - +
+ +
); } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index af235b4..51fd9ff 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -41,33 +41,30 @@ function formatMonthLabel(key: string): string { } /** - * Returns premium styling and icons for stores. - * Indigo for first store, Amber for second, Slate for others. + * 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, index: number) { - const initial = name.charAt(0).toUpperCase(); - if (index === 0) { - return { - bg: "rgba(99, 102, 241, 0.1)", // Indigo - text: "#818cf8", - border: "rgba(99, 102, 241, 0.2)", - label: initial - }; +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(); } - if (index === 1) { - return { - bg: "rgba(245, 158, 11, 0.1)", // Amber - text: "#fbbf24", - border: "rgba(245, 158, 11, 0.2)", - label: initial - }; + + 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 }; } - return { - bg: "var(--bg-elevated)", - text: "var(--text-muted)", - border: "var(--border)", - label: initial - }; } interface HeatmapGridProps { @@ -278,6 +275,16 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { ); }, }, + { + accessorKey: "reference", + header: () =>
Réf. Fournisseur
, + size: 130, + cell: ({ getValue }) => ( + + {getValue() || "-"} + + ), + }, { accessorKey: "libelle1", header: "Désignation", @@ -288,12 +295,12 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { {row.original.libelle1}
- {row.original.workingStores.map((magasin, idx) => { - const config = getStoreConfig(magasin, idx); + {row.original.workingStores.map((magasin) => { + const config = getStoreConfig(magasin); return (
Init.
, + header: () =>
Init.
, size: 56, cell: ({ row }) => ( -
+
{row.original.codeGammeInit || "-"}
), @@ -402,10 +409,14 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { }, { id: "ai", - header: "Recommandation IA", + header: () =>
Recommandation IA
, size: 230, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => ( +
+ +
+ ), }, ], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !