feat: Add virtualized heatmap grid component with dynamic columns, filtering, and row selection.

This commit is contained in:
Michael committed 2026-03-18 20:34:23 +01:00
1 parent 8bd7ae8c45
commit 9aca3b8ead
5 files changed
+57 -82

No files matched your search

+3 -5
View File
@@ -1,4 +1,4 @@
import { getAvailableNomenclature, getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
@@ -22,11 +22,10 @@ export default async function GridPage({ searchParams }: GridPageProps) {
code3: params.code3 || null,
};
// 1. Fetch available suppliers, stores & nomenclature hierarchy
const [fournisseurs, magasins, nomenclature] = await Promise.all([
// 1. Fetch available suppliers & stores
const [fournisseurs, magasins] = await Promise.all([
getFournisseurs(),
getMagasins(),
getAvailableNomenclature()
]);
// 2. If no supplier selected, show compact selection UI
@@ -48,7 +47,6 @@ export default async function GridPage({ searchParams }: GridPageProps) {
fournisseurs={fournisseurs}
magasins={magasins}
magasin={magasin}
nomenclature={nomenclature}
/>
);
}
+2 -3
View File
@@ -22,10 +22,9 @@ interface GridClientProps {
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
magasin: string;
nomenclature: any; // Type defined in components if needed
}
export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins, magasin, nomenclature }: GridClientProps) {
export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
const setRows = useGridStore((s) => s.setRows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const searchParams = useSearchParams();
@@ -121,7 +120,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} />
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
</div>
{/* Bulk toolbar (contextual) */}
@@ -22,15 +22,25 @@ import { NomenclatureFilter } from "./nomenclature-filter";
interface GridFilterBarProps {
fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[];
nomenclature: any;
}
export function GridFilterBar({ fournisseurs, magasins, nomenclature }: GridFilterBarProps) {
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const { filters, setFilter, rows, draftChanges } = useGridStore();
const router = useRouter();
const searchParams = useSearchParams();
const [isRefreshing, setIsRefreshing] = React.useState(false);
// Options nomenclature (famille) construites depuis les rows chargées
const code3Options = useMemo(() => {
const map = new Map<string, string>();
rows.forEach(r => {
if (r.code3 && r.libelle3) map.set(r.code3, r.libelle3);
});
return Array.from(map.entries())
.map(([code, label]) => ({ code, label }))
.sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }));
}, [rows]);
// Compute dynamic counts and present Gammes
const { gammeCounts, activeGammes } = React.useMemo(() => {
const counts: Record<string, number> = { Total: 0 };
@@ -195,7 +205,7 @@ export function GridFilterBar({ fournisseurs, magasins, nomenclature }: GridFilt
</div>
{/* Nomenclature filter */}
<NomenclatureFilter hierarchy={nomenclature} />
<NomenclatureFilter options={code3Options} />
</div>
);
}
+29 -8
View File
@@ -178,7 +178,23 @@ 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 { rows, filters, displayDensity, draftChanges } = useGridStore();
// Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => {
const { code3, codeGamme } = filters;
if (!code3 && !codeGamme) return rows;
return rows.filter(r => {
if (code3 && r.code3 !== 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, filters, draftChanges]);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
@@ -351,12 +367,17 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
{
accessorKey: "libelle3",
header: "Famille",
size: 160,
cell: ({ getValue }) => (
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }}>
{getValue<string>()}
</span>
),
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>
);
},
},
{
accessorKey: "score",
@@ -452,7 +473,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
], [MONTHS_12]); // Dépendances extrêmement stables : pas de re-render du header !
const table = useReactTable({
data: rows,
data: filteredData,
columns,
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting,
@@ -1,67 +1,18 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
import { ChevronDown, Filter } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface NomenclatureFilterProps {
hierarchy: any;
options: { code: string; label: string }[];
className?: string;
}
export function NomenclatureFilter({ hierarchy, className }: NomenclatureFilterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const selected1 = searchParams.get("code1");
const selected2 = searchParams.get("code2");
const selected3 = searchParams.get("code3");
// Valeur composite pour le select : "type:code"
const currentValue = selected3 ? `code3:${selected3}` :
selected2 ? `code2:${selected2}` :
selected1 ? `code1:${selected1}` : "";
const updateFilter = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
// Reset all nomenclature filters
params.delete("code1");
params.delete("code2");
params.delete("code3");
if (value) {
const [type, code] = value.split(":");
params.set(type, code);
}
router.push(`/grid?${params.toString()}`);
};
// Aplatir la hiérarchie pour le menu unique et trier par code
const options: { label: string; value: string; level: number; code: string }[] = [];
// Sort Secteurs
const sortedSecteurs = Object.entries(hierarchy).sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }));
sortedSecteurs.forEach(([c1, s]: [string, any]) => {
options.push({ label: s.label, value: `code1:${c1}`, level: 0, code: c1 });
// Sort Rayons
const sortedRayons = Object.entries(s.children).sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }));
sortedRayons.forEach(([c2, r]: [string, any]) => {
options.push({ label: r.label, value: `code2:${c2}`, level: 1, code: c2 });
// Sort Familles
const sortedFamilles = [...r.children].sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }));
sortedFamilles.forEach((f: any) => {
options.push({ label: f.label, value: `code3:${f.code}`, level: 2, code: f.code });
});
});
});
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
const code3 = useGridStore((s) => s.filters.code3);
const setFilter = useGridStore((s) => s.setFilter);
return (
<div className={cn("relative group", className)}>
@@ -70,19 +21,15 @@ export function NomenclatureFilter({ hierarchy, className }: NomenclatureFilterP
</div>
<select
value={currentValue}
onChange={(e) => updateFilter(e.target.value)}
value={code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-12 appearance-none w-full min-w-[240px]"
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
>
<option value="">Toute la nomenclature</option>
{options.map((opt, idx) => (
<option
key={`${opt.value}-${idx}`}
value={opt.value}
style={{ paddingLeft: `${opt.level * 12}px` }}
>
{"\u00A0".repeat(opt.level * 4)}{opt.label}
{options.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.code} — {opt.label}
</option>
))}
</select>