mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 05:58:10 +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 { 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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user