mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 15:38:09 +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 = {
|
const filters = {
|
||||||
code1: params.code1 || null,
|
code1: params.code1 || null,
|
||||||
code2: params.code2 || 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
|
// 1. Fetch available suppliers & stores
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const magasin = searchParams.get("magasin") ?? "TOTAL";
|
const magasin = searchParams.get("magasin") ?? "TOTAL";
|
||||||
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
|
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> = {
|
const filters: Partial<GridFilters> = {
|
||||||
code1: searchParams.get("code1"),
|
code1: searchParams.get("code1"),
|
||||||
code2: searchParams.get("code2"),
|
code2: searchParams.get("code2"),
|
||||||
code3: searchParams.get("code3"),
|
|
||||||
};
|
};
|
||||||
const forceRefresh = searchParams.get("refresh") === "1";
|
const forceRefresh = searchParams.get("refresh") === "1";
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
const rows = useGridStore((s) => s.rows);
|
const rows = useGridStore((s) => s.rows);
|
||||||
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||||
const setFilter = useGridStore((s) => s.setFilter);
|
const setFilter = useGridStore((s) => s.setFilter);
|
||||||
|
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -95,10 +96,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
if (prevFournisseurRef.current !== codeFournisseur) {
|
if (prevFournisseurRef.current !== codeFournisseur) {
|
||||||
setFilter("codeGamme", null);
|
setFilter("codeGamme", null);
|
||||||
setFilter("code3", null);
|
setCode3Filter(null);
|
||||||
prevFournisseurRef.current = codeFournisseur;
|
prevFournisseurRef.current = codeFournisseur;
|
||||||
}
|
}
|
||||||
}, [codeFournisseur, setFilter, isMounted]);
|
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
|
||||||
|
|
||||||
const refreshToken = searchParams.get("_refresh");
|
const refreshToken = searchParams.get("_refresh");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,9 +109,11 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("fournisseur", codeFournisseur);
|
params.set("fournisseur", codeFournisseur);
|
||||||
params.set("magasin", magasin || "TOTAL");
|
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.code1) params.set("code1", filters.code1);
|
||||||
if (filters.code2) params.set("code2", filters.code2);
|
if (filters.code2) params.set("code2", filters.code2);
|
||||||
if (filters.code3) params.set("code3", filters.code3);
|
|
||||||
if (refreshToken) params.set("refresh", "1");
|
if (refreshToken) params.set("refresh", "1");
|
||||||
|
|
||||||
let accumulatedRows: ProductRow[] = [];
|
let accumulatedRows: ProductRow[] = [];
|
||||||
@@ -173,7 +176,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
|||||||
loadRows();
|
loadRows();
|
||||||
|
|
||||||
return () => controller.abort();
|
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)
|
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -414,8 +414,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const { code3, codeGamme } = filters;
|
const { code3, codeGamme } = filters;
|
||||||
if (!code3 && !codeGamme) return rows;
|
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 => {
|
return rows.filter(r => {
|
||||||
if (code3 && r.code3 !== code3) return false;
|
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
|
||||||
if (codeGamme) {
|
if (codeGamme) {
|
||||||
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
|
const g = draftChanges[r.codein] ?? r.codeGamme ?? "";
|
||||||
const norm = g.trim() === "" ? "Aucune" : g;
|
const norm = g.trim() === "" ? "Aucune" : g;
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
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";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
|
|
||||||
interface NomenclatureFilterProps {
|
interface NomenclatureFilterProps {
|
||||||
@@ -10,33 +16,143 @@ interface NomenclatureFilterProps {
|
|||||||
className?: string;
|
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) {
|
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
|
||||||
const code3 = useGridStore((s) => s.filters.code3);
|
const selection = useGridStore((s) => s.filters.code3);
|
||||||
const setFilter = useGridStore((s) => s.setFilter);
|
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 (
|
return (
|
||||||
<div className={cn("relative group", className)}>
|
<div className={cn("relative", className)}>
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
|
<DropdownMenu>
|
||||||
<Filter className="h-3.5 w-3.5 opacity-40 group-focus-within:opacity-100 transition-opacity" style={{ color: "var(--text-primary)" }} />
|
<DropdownMenuTrigger asChild>
|
||||||
</div>
|
<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
|
<DropdownMenuContent align="start" className="w-[320px] max-h-[420px] overflow-y-auto p-0">
|
||||||
value={code3 ?? ""}
|
<div className="sticky top-0 z-10 p-2 space-y-2" style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}>
|
||||||
onChange={(e) => setFilter("code3", e.target.value || null)}
|
<div className="relative">
|
||||||
className="apple-input pr-12 appearance-none w-full min-w-[240px]"
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 opacity-40" />
|
||||||
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
|
<input
|
||||||
>
|
value={recherche}
|
||||||
<option value="">Toute la nomenclature</option>
|
onChange={(e) => setRecherche(e.target.value)}
|
||||||
{options.map((opt) => (
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
<option key={opt.code} value={opt.code}>
|
placeholder="Filtrer les nomenclatures"
|
||||||
{opt.code} — {opt.label}
|
className="w-full rounded-lg pl-7 pr-2 py-1.5 text-[12px] outline-none"
|
||||||
</option>
|
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||||
))}
|
/>
|
||||||
</select>
|
</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">
|
{visibles.length === 0 ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<p className="p-3 text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune nomenclature ne correspond.</p>
|
||||||
</div>
|
) : (
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,9 @@ interface GridState {
|
|||||||
clearDrafts: (codeins: string[]) => void;
|
clearDrafts: (codeins: string[]) => void;
|
||||||
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
|
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
|
||||||
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
|
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;
|
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
|
||||||
setActiveGridQuery: (query: string) => void;
|
setActiveGridQuery: (query: string) => void;
|
||||||
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
|
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
|
||||||
@@ -172,6 +174,9 @@ export const useGridStore = create<GridState>()(
|
|||||||
setFilter: (key, value) => {
|
setFilter: (key, value) => {
|
||||||
set((state) => ({ ...state, filters: { ...state.filters, [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 }),
|
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||||
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
||||||
restoreSnapshot: (changes) => {
|
restoreSnapshot: (changes) => {
|
||||||
|
|||||||
+7
-1
@@ -149,7 +149,13 @@ export interface GridFilters {
|
|||||||
codeFournisseur: string | null;
|
codeFournisseur: string | null;
|
||||||
code1: string | null;
|
code1: string | null;
|
||||||
code2: 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;
|
codeGamme: GammeCode | null;
|
||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user