feat(grille): filtre nomenclature en multi-sélection

Le filtre était un <select> à valeur unique : impossible de retenir plusieurs
nomenclatures, ni de partir de tout pour retirer les postes non voulus — ce qui
est la manière naturelle de travailler quand un fournisseur en compte des
dizaines.

Remplacé par une liste déroulante à cases, avec recherche, « Tout cocher »,
« Tout décocher », « Ne garder que ces N » sur le résultat d'une recherche, et
une croix pour tout réafficher. Décocher depuis l'état « tout » matérialise la
liste complète puis en retire le poste, sinon le premier décochage n'aurait
rien fait.

filters.code3 passe de `string | null` à `string[] | null` : `null` = aucun
filtre, `[]` = rien de coché. Distinguer les deux évite la valeur sentinelle
qu'imposerait un simple tableau, et rend « Tout décocher » sans ambiguïté.
L'URL accepte une liste séparée par des virgules.

Au passage : la Grille rechargeait tout depuis le serveur à chaque changement
de nomenclature, alors que getProductRows ignore ce filtre et que le tri se
fait en local. Le paramètre n'est plus transmis ni dans les dépendances de
l'effet — le filtrage est désormais instantané, ce qui compte d'autant plus
avec plusieurs cases à cocher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-07 08:46:39 +00:00
1 parent 0bd7ec9798
commit 720cf3c7a1
7 files changed
+167 -32

No files matched your search

+2 -1
View File
@@ -19,7 +19,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
const filters = {
code1: params.code1 || null,
code2: params.code2 || null,
code3: params.code3 || null,
// Liste séparée par des virgules : le filtre accepte plusieurs nomenclatures.
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
};
// 1. Fetch available suppliers & stores
+2 -1
View File
@@ -18,10 +18,11 @@ export async function GET(request: NextRequest) {
const magasin = searchParams.get("magasin") ?? "TOTAL";
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
const filters: Partial<GridFilters> = {
code1: searchParams.get("code1"),
code2: searchParams.get("code2"),
code3: searchParams.get("code3"),
};
const forceRefresh = searchParams.get("refresh") === "1";
+7 -4
View File
@@ -58,6 +58,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
@@ -95,10 +96,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
if (!isMounted) return;
if (prevFournisseurRef.current !== codeFournisseur) {
setFilter("codeGamme", null);
setFilter("code3", null);
setCode3Filter(null);
prevFournisseurRef.current = codeFournisseur;
}
}, [codeFournisseur, setFilter, isMounted]);
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
const refreshToken = searchParams.get("_refresh");
useEffect(() => {
@@ -108,9 +109,11 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
// faire voyager relançait un chargement complet à chaque case cochée.
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (filters.code3) params.set("code3", filters.code3);
if (refreshToken) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
@@ -173,7 +176,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
@@ -414,8 +414,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const filteredData = useMemo(() => {
const { code3, codeGamme } = filters;
if (!code3 && !codeGamme) return rows;
// Set plutôt que includes() : la liste peut compter des dizaines de
// nomenclatures, et le filtre est réévalué pour chaque ligne.
const nomenclatures = code3 ? new Set(code3) : null;
return rows.filter(r => {
if (code3 && r.code3 !== code3) return false;
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
if (codeGamme) {
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
const norm = g.trim() === "" ? "Aucune" : g;
@@ -2,7 +2,13 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Filter } from "lucide-react";
import { ChevronDown, Filter, Search, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface NomenclatureFilterProps {
@@ -10,33 +16,143 @@ interface NomenclatureFilterProps {
className?: string;
}
/**
* Filtre multi-nomenclatures de la Grille.
*
* Une liste déroulante à cases plutôt qu'un `<select>` simple : on veut pouvoir
* en retenir plusieurs, et surtout partir de « tout » pour retirer les postes
* dont on ne veut pas — ce qui est la manière naturelle de travailler quand un
* fournisseur en compte des dizaines.
*
* Convention : `null` = aucun filtre (tout s'affiche), `[]` = rien de coché
* (aucune ligne). Les distinguer évite la valeur sentinelle qu'imposerait un
* simple tableau, et rend « Tout décocher » possible sans ambiguïté.
*/
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
const code3 = useGridStore((s) => s.filters.code3);
const setFilter = useGridStore((s) => s.setFilter);
const selection = useGridStore((s) => s.filters.code3);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const [recherche, setRecherche] = React.useState("");
/** `null` = tout retenu : les cases sont alors toutes cochées. */
const tout = selection === null;
const selectionnes = React.useMemo(() => new Set(selection ?? []), [selection]);
const visibles = React.useMemo(() => {
const q = recherche.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => `${o.code} ${o.label}`.toLowerCase().includes(q));
}, [options, recherche]);
const basculer = (code: string, coche: boolean) => {
// Décocher depuis l'état « tout » doit retirer ce seul poste : on matérialise
// donc la liste complète, sans quoi le premier décochage ne ferait rien.
const base = tout ? options.map((o) => o.code) : (selection ?? []);
const suivant = coche ? [...base, code] : base.filter((c) => c !== code);
// Tout re-cocher revient à ne plus filtrer : on repasse à `null` pour que
// l'état reste lisible (et l'URL propre).
setCode3Filter(suivant.length === options.length ? null : suivant);
};
const libelle = tout
? "Toute la nomenclature"
: selectionnes.size === 0
? "Aucune nomenclature"
: selectionnes.size === 1
? (options.find((o) => o.code === selection![0])?.label ?? selection![0])
: `${selectionnes.size} / ${options.length} nomenclatures`;
return (
<div className={cn("relative group", className)}>
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<Filter className="h-3.5 w-3.5 opacity-40 group-focus-within:opacity-100 transition-opacity" style={{ color: "var(--text-primary)" }} />
</div>
<div className={cn("relative", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="apple-input w-full min-w-[240px] flex items-center gap-2 text-left"
style={{ fontSize: "12px", height: "36px", paddingLeft: "12px", paddingRight: "12px" }}
>
<Filter className="h-3.5 w-3.5 opacity-40 shrink-0" />
<span className="flex-1 truncate" style={{ color: tout ? undefined : "var(--text-primary)" }}>
{libelle}
</span>
{!tout && (
<span
role="button"
tabIndex={0}
title="Tout réafficher"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); } }}
className="shrink-0 rounded p-0.5 hover:bg-[var(--bg-elevated)]"
>
<X className="h-3.5 w-3.5 opacity-60" />
</span>
)}
<ChevronDown className="h-4 w-4 opacity-40 shrink-0" />
</button>
</DropdownMenuTrigger>
<select
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) => (
<option key={opt.code} value={opt.code}>
{opt.code} — {opt.label}
</option>
))}
</select>
<DropdownMenuContent align="start" className="w-[320px] max-h-[420px] overflow-y-auto p-0">
<div className="sticky top-0 z-10 p-2 space-y-2" style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 opacity-40" />
<input
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="Filtrer les nomenclatures"
className="w-full rounded-lg pl-7 pr-2 py-1.5 text-[12px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
</div>
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setCode3Filter(null)}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout cocher
</button>
<button
type="button"
// Un code vide ne correspond à aucune ligne : c'est ainsi qu'on
// exprime « rien de coché » sans confondre avec « pas de filtre ».
onClick={() => setCode3Filter([])}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout décocher
</button>
{recherche.trim() && visibles.length > 0 && (
<button
type="button"
onClick={() => setCode3Filter(visibles.map((o) => o.code))}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)] ml-auto"
style={{ color: "var(--accent)" }}
>
Ne garder que ces {visibles.length}
</button>
)}
</div>
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none opacity-40">
<ChevronDown className="h-4 w-4" />
</div>
{visibles.length === 0 ? (
<p className="p-3 text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune nomenclature ne correspond.</p>
) : (
visibles.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.code}
className="text-xs cursor-pointer"
checked={tout || selectionnes.has(opt.code)}
onCheckedChange={(v) => basculer(opt.code, !!v)}
onSelect={(e: Event) => e.preventDefault()}
>
<span className="font-mono opacity-60 mr-1.5">{opt.code}</span>
{opt.label}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
+6 -1
View File
@@ -33,7 +33,9 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
setActiveGridQuery: (query: string) => void;
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
@@ -172,6 +174,9 @@ export const useGridStore = create<GridState>()(
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
setCode3Filter: (codes) => {
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
restoreSnapshot: (changes) => {
+7 -1
View File
@@ -149,7 +149,13 @@ export interface GridFilters {
codeFournisseur: string | null;
code1: string | null;
code2: string | null;
code3: string | null;
/**
* Nomenclatures retenues (codes `code3`).
* `null` = aucun filtre (tout s'affiche) ; `[]` = rien de coché (aucune
* ligne). Distinguer les deux évite la valeur sentinelle qu'imposerait un
* simple tableau.
*/
code3: string[] | null;
codeGamme: GammeCode | null;
search: string;
}