mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
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:
7 files changed
+167
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user