mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Add virtualized heatmap grid component with dynamic columns, filtering, and row selection.
This commit is contained in:
1 parent
8bd7ae8c45
commit
9aca3b8ead
5 files changed
+57
-82
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user