mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 21:18:16 +02:00
feat: implement HeatmapGrid component using TanStack Table and Virtual for efficient product data visualization with state management and performance optimizations.
This commit is contained in:
1 parent
c7fa25a5d1
commit
4de420cd48
3 files changed
+187
-56
No files matched your search
@@ -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 (
|
||||||
|
<div className="relative z-50 print:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="btn-action btn-action-secondary flex items-center gap-2"
|
||||||
|
title="Exporter les données"
|
||||||
|
>
|
||||||
|
<Download className="w-[14px] h-[14px]" />
|
||||||
|
<span className="text-[13px] font-semibold">Exporter</span>
|
||||||
|
<ChevronDown className="w-[12px] h-[12px] opacity-60" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 mt-2 w-56 rounded-xl border z-50 overflow-hidden shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
borderColor: "var(--border-strong)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<button
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[var(--text-primary)]">Gamme A vers Excel</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Gencode, Réf, Libellé...</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="my-1 h-px bg-[var(--border)] mx-2" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePrintPDF}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-lg transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-rose-500" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[var(--text-primary)]">Tableau en PDF</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-muted)] mt-0.5">Mise en page épurée (Réf. incluse)</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
|||||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
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 { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||||
import type { ProductRow } from "@/types/grid";
|
import type { ProductRow } from "@/types/grid";
|
||||||
@@ -89,37 +90,40 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
|
|||||||
{" "}• {initialRows.length} références
|
{" "}• {initialRows.length} références
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{hasDrafts && (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<ExportDropdown />
|
||||||
onClick={handleSave}
|
{hasDrafts && (
|
||||||
disabled={isPending}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
|
onClick={handleSave}
|
||||||
>
|
disabled={isPending}
|
||||||
{isPending ? (
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
>
|
||||||
) : saveStatus === "success" ? (
|
{isPending ? (
|
||||||
<CheckCircle className="w-4 h-4" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : saveStatus === "error" ? (
|
) : saveStatus === "success" ? (
|
||||||
<AlertCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
) : null}
|
) : saveStatus === "error" ? (
|
||||||
{isPending
|
<AlertCircle className="w-4 h-4" />
|
||||||
? "Sauvegarde..."
|
) : null}
|
||||||
: saveStatus === "success"
|
{isPending
|
||||||
? "Sauvegardé !"
|
? "Sauvegarde..."
|
||||||
: saveStatus === "error"
|
: saveStatus === "success"
|
||||||
? "Erreur"
|
? "Sauvegardé !"
|
||||||
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
|
: saveStatus === "error"
|
||||||
</button>
|
? "Erreur"
|
||||||
)}
|
: `Valider ${count} changement${count > 1 ? "s" : ""}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0 print:hidden">
|
||||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} />
|
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk toolbar (contextual) */}
|
{/* Bulk toolbar (contextual) */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0 print:hidden">
|
||||||
<BulkActionToolbar
|
<BulkActionToolbar
|
||||||
selectedCodeins={selectedCodeins}
|
selectedCodeins={selectedCodeins}
|
||||||
onClearSelection={() => setSelectedCodeins([])}
|
onClearSelection={() => setSelectedCodeins([])}
|
||||||
@@ -134,7 +138,9 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<FloatingSummaryBar />
|
<div className="print:hidden">
|
||||||
|
<FloatingSummaryBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,33 +41,30 @@ function formatMonthLabel(key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns premium styling and icons for stores.
|
* Returns premium styling and color for stores based on the store name's hash.
|
||||||
* Indigo for first store, Amber for second, Slate for others.
|
* This guarantees a consistent color for a given store, regardless of its position in the list.
|
||||||
*/
|
*/
|
||||||
function getStoreConfig(name: string, index: number) {
|
function getStoreConfig(name: string) {
|
||||||
const initial = name.charAt(0).toUpperCase();
|
const words = name.trim().split(/\s+/);
|
||||||
if (index === 0) {
|
let label = words[0].charAt(0).toUpperCase();
|
||||||
return {
|
if (words.length > 1) {
|
||||||
bg: "rgba(99, 102, 241, 0.1)", // Indigo
|
label += words[1].charAt(0).toUpperCase();
|
||||||
text: "#818cf8",
|
|
||||||
border: "rgba(99, 102, 241, 0.2)",
|
|
||||||
label: initial
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (index === 1) {
|
|
||||||
return {
|
let hash = 0;
|
||||||
bg: "rgba(245, 158, 11, 0.1)", // Amber
|
for (let i = 0; i < name.length; i++) {
|
||||||
text: "#fbbf24",
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
border: "rgba(245, 158, 11, 0.2)",
|
}
|
||||||
label: initial
|
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 {
|
interface HeatmapGridProps {
|
||||||
@@ -278,6 +275,16 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "reference",
|
||||||
|
header: () => <div className="text-left w-full">Réf. Fournisseur</div>,
|
||||||
|
size: 130,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-[12px] truncate block text-left w-full font-mono-nums opacity-80" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{getValue<string>() || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "libelle1",
|
accessorKey: "libelle1",
|
||||||
header: "Désignation",
|
header: "Désignation",
|
||||||
@@ -288,12 +295,12 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
{row.original.libelle1}
|
{row.original.libelle1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1.5 shrink-0">
|
<div className="flex gap-1.5 shrink-0">
|
||||||
{row.original.workingStores.map((magasin, idx) => {
|
{row.original.workingStores.map((magasin) => {
|
||||||
const config = getStoreConfig(magasin, idx);
|
const config = getStoreConfig(magasin);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={magasin}
|
key={magasin}
|
||||||
title={`Travaillé par : ${magasin} (${idx + 1 === 1 ? 'Magasin 1' : 'Magasin 2'})`}
|
title={`Travaillé par : ${magasin}`}
|
||||||
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
className="px-1.5 py-0.5 rounded-md flex items-center gap-1 text-[10px] font-black border shadow-sm transition-transform hover:scale-110"
|
||||||
style={{
|
style={{
|
||||||
background: config.bg,
|
background: config.bg,
|
||||||
@@ -386,10 +393,10 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gammeInitial",
|
id: "gammeInitial",
|
||||||
header: () => <div className="text-center w-full">Init.</div>,
|
header: () => <div className="text-center w-full print:hidden">Init.</div>,
|
||||||
size: 56,
|
size: 56,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-center font-bold text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
<div className="text-center font-bold text-[12px] print:hidden" style={{ color: "var(--text-secondary)" }}>
|
||||||
{row.original.codeGammeInit || "-"}
|
{row.original.codeGammeInit || "-"}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -402,10 +409,14 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ai",
|
id: "ai",
|
||||||
header: "Recommandation IA",
|
header: () => <div className="print:hidden">Recommandation IA</div>,
|
||||||
size: 230,
|
size: 230,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => <AiInsightBlock row={row.original} />,
|
cell: ({ row }) => (
|
||||||
|
<div className="print:hidden w-full h-full">
|
||||||
|
<AiInsightBlock row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !
|
], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user