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 { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
@@ -22,11 +22,10 @@ export default async function GridPage({ searchParams }: GridPageProps) {
code3: params.code3 || null, code3: params.code3 || null,
}; };
// 1. Fetch available suppliers, stores & nomenclature hierarchy // 1. Fetch available suppliers & stores
const [fournisseurs, magasins, nomenclature] = await Promise.all([ const [fournisseurs, magasins] = await Promise.all([
getFournisseurs(), getFournisseurs(),
getMagasins(), getMagasins(),
getAvailableNomenclature()
]); ]);
// 2. If no supplier selected, show compact selection UI // 2. If no supplier selected, show compact selection UI
@@ -48,7 +47,6 @@ export default async function GridPage({ searchParams }: GridPageProps) {
fournisseurs={fournisseurs} fournisseurs={fournisseurs}
magasins={magasins} magasins={magasins}
magasin={magasin} magasin={magasin}
nomenclature={nomenclature}
/> />
); );
} }
+2 -3
View File
@@ -22,10 +22,9 @@ interface GridClientProps {
fournisseurs: { code: string; nom: string }[]; fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[]; magasins: { code: string; nom: string }[];
magasin: 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 setRows = useGridStore((s) => s.setRows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -121,7 +120,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
{/* Filters */} {/* Filters */}
<div className="shrink-0 print:hidden"> <div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} /> <GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
</div> </div>
{/* Bulk toolbar (contextual) */} {/* Bulk toolbar (contextual) */}
@@ -22,15 +22,25 @@ import { NomenclatureFilter } from "./nomenclature-filter";
interface GridFilterBarProps { interface GridFilterBarProps {
fournisseurs: { code: string; nom: string }[]; fournisseurs: { code: string; nom: string }[];
magasins: { 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 { filters, setFilter, rows, draftChanges } = useGridStore();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isRefreshing, setIsRefreshing] = React.useState(false); 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 // Compute dynamic counts and present Gammes
const { gammeCounts, activeGammes } = React.useMemo(() => { const { gammeCounts, activeGammes } = React.useMemo(() => {
const counts: Record<string, number> = { Total: 0 }; const counts: Record<string, number> = { Total: 0 };
@@ -195,7 +205,7 @@ export function GridFilterBar({ fournisseurs, magasins, nomenclature }: GridFilt
</div> </div>
{/* Nomenclature filter */} {/* Nomenclature filter */}
<NomenclatureFilter hierarchy={nomenclature} /> <NomenclatureFilter options={code3Options} />
</div> </div>
); );
} }
+29 -8
View File
@@ -178,7 +178,23 @@ GridRow.displayName = "GridRow";
export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme. // 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 [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore(); const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
@@ -351,12 +367,17 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
{ {
accessorKey: "libelle3", accessorKey: "libelle3",
header: "Famille", header: "Famille",
size: 160, size: 200,
cell: ({ getValue }) => ( cell: ({ row }) => {
<span className="text-[12px] truncate block text-left w-full opacity-70" style={{ color: "var(--text-secondary)" }}> const code = row.original.code3;
{getValue<string>()} const label = row.original.libelle3;
</span> 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", accessorKey: "score",
@@ -452,7 +473,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
], [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 !
const table = useReactTable({ const table = useReactTable({
data: rows, data: filteredData,
columns, columns,
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing }, state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting, onSortingChange: setSorting,
@@ -1,67 +1,18 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDown, Filter } from "lucide-react"; import { ChevronDown, Filter } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface NomenclatureFilterProps { interface NomenclatureFilterProps {
hierarchy: any; options: { code: string; label: string }[];
className?: string; className?: string;
} }
export function NomenclatureFilter({ hierarchy, className }: NomenclatureFilterProps) { export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
const router = useRouter(); const code3 = useGridStore((s) => s.filters.code3);
const searchParams = useSearchParams(); const setFilter = useGridStore((s) => s.setFilter);
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 });
});
});
});
return ( return (
<div className={cn("relative group", className)}> <div className={cn("relative group", className)}>
@@ -70,19 +21,15 @@ export function NomenclatureFilter({ hierarchy, className }: NomenclatureFilterP
</div> </div>
<select <select
value={currentValue} value={code3 ?? ""}
onChange={(e) => updateFilter(e.target.value)} onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-12 appearance-none w-full min-w-[240px]" className="apple-input pr-12 appearance-none w-full min-w-[240px]"
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }} style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
> >
<option value="">Toute la nomenclature</option> <option value="">Toute la nomenclature</option>
{options.map((opt, idx) => ( {options.map((opt) => (
<option <option key={opt.code} value={opt.code}>
key={`${opt.value}-${idx}`} {opt.code} — {opt.label}
value={opt.value}
style={{ paddingLeft: `${opt.level * 12}px` }}
>
{"\u00A0".repeat(opt.level * 4)}{opt.label}
</option> </option>
))} ))}
</select> </select>