mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-17 20:30:07 +02:00
feat(grid): implement hierarchical nomenclature filter (Secteur > Rayon > Famille)
This commit is contained in:
1 parent
de7aa1560e
commit
33615bb1bd
10 files changed
+310
-57
No files matched your search
@@ -20,9 +20,10 @@ 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 }: GridClientProps) {
|
||||
export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins, magasin, nomenclature }: GridClientProps) {
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
|
||||
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
|
||||
@@ -98,7 +99,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} />
|
||||
|
||||
{/* Bulk toolbar (contextual) */}
|
||||
<BulkActionToolbar
|
||||
|
||||
@@ -16,13 +16,15 @@ const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
|
||||
import { SupplierCombobox } from "./supplier-combobox";
|
||||
import { StoreCombobox } from "./store-combobox";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { NomenclatureFilter } from "./nomenclature-filter";
|
||||
|
||||
interface GridFilterBarProps {
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
magasins: { code: string; nom: string }[];
|
||||
nomenclature: any;
|
||||
}
|
||||
|
||||
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
export function GridFilterBar({ fournisseurs, magasins, nomenclature }: GridFilterBarProps) {
|
||||
const { filters, setFilter } = useGridStore();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -107,20 +109,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Nomenclature filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filters.code3 ?? ""}
|
||||
onChange={(e) => setFilter("code3", e.target.value || null)}
|
||||
className="apple-input pr-8 appearance-none"
|
||||
style={{ width: "180px" }}
|
||||
>
|
||||
<option value="">Toutes nomenclatures</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
</div>
|
||||
<NomenclatureFilter hierarchy={nomenclature} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check } from "lucide-react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
@@ -40,6 +40,23 @@ function formatMonthLabel(key: string): string {
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic color for a store name.
|
||||
*/
|
||||
function getStoreColor(name: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
// Use HSL for better visual distribution
|
||||
const h = Math.abs(hash) % 360;
|
||||
return {
|
||||
bg: `hsl(${h}, 70%, 45%)`,
|
||||
border: `hsl(${h}, 70%, 35%)`,
|
||||
text: "#fff"
|
||||
};
|
||||
}
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
}
|
||||
@@ -121,7 +138,37 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
||||
accessorKey: "codein",
|
||||
header: "Code",
|
||||
size: 90,
|
||||
cell: ({ getValue }) => <span className="font-mono-nums font-bold text-[12px] tracking-tight opacity-70" style={{ color: "var(--text-muted)" }}>{getValue<string>()}</span>,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue<string>();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
||||
title="Copier le code"
|
||||
>
|
||||
<span className="font-mono-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
||||
{value}
|
||||
</span>
|
||||
<div className="shrink-0 transition-all duration-200">
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle1",
|
||||
@@ -133,21 +180,23 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
||||
{row.original.libelle1}
|
||||
</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{row.original.workingStores.map((magasin) => (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${magasin}`}
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-black border"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-secondary)",
|
||||
boxShadow: "var(--shadow-xs)"
|
||||
}}
|
||||
>
|
||||
{magasin.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{row.original.workingStores.map((magasin) => {
|
||||
const colors = getStoreColor(magasin);
|
||||
return (
|
||||
<div
|
||||
key={magasin}
|
||||
title={`Travaillé par : ${magasin}`}
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-black border shadow-sm"
|
||||
style={{
|
||||
background: colors.bg,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
{magasin.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronRight, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
interface NomenclatureFilterProps {
|
||||
hierarchy: any;
|
||||
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");
|
||||
|
||||
const updateFilter = (params: Record<string, string | null>) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) nextParams.set(key, value);
|
||||
else nextParams.delete(key);
|
||||
});
|
||||
|
||||
// Reset children if parent changes
|
||||
if (params.code1 !== undefined) {
|
||||
nextParams.delete("code2");
|
||||
nextParams.delete("code3");
|
||||
}
|
||||
if (params.code2 !== undefined) {
|
||||
nextParams.delete("code3");
|
||||
}
|
||||
|
||||
router.push(`/grid?${nextParams.toString()}`);
|
||||
};
|
||||
|
||||
const l1Options = Object.entries(hierarchy).map(([code, data]: [string, any]) => ({
|
||||
code,
|
||||
label: data.label
|
||||
})).sort((a, b) => a.code.localeCompare(b.code));
|
||||
|
||||
const l2Options = selected1 && hierarchy[selected1]
|
||||
? Object.entries(hierarchy[selected1].children).map(([code, data]: [string, any]) => ({
|
||||
code,
|
||||
label: data.label
|
||||
})).sort((a, b) => a.code.localeCompare(b.code))
|
||||
: [];
|
||||
|
||||
const l3Options = selected1 && selected2 && hierarchy[selected1]?.children[selected2]
|
||||
? hierarchy[selected1].children[selected2].children
|
||||
.sort((a: any, b: any) => a.code.localeCompare(b.code))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
{/* Level 1 */}
|
||||
<select
|
||||
value={selected1 || ""}
|
||||
onChange={(e) => updateFilter({ code1: e.target.value || null })}
|
||||
className="apple-input text-[12px] h-8 min-w-[140px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<option value="">Tous les Secteurs</option>
|
||||
{l1Options.map(opt => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ChevronRight className="w-3 h-3 opacity-30" />
|
||||
|
||||
{/* Level 2 */}
|
||||
<select
|
||||
value={selected2 || ""}
|
||||
onChange={(e) => updateFilter({ code2: e.target.value || null })}
|
||||
disabled={!selected1}
|
||||
className="apple-input text-[12px] h-8 min-w-[140px] disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<option value="">Tous les Rayons</option>
|
||||
{l2Options.map(opt => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ChevronRight className="w-3 h-3 opacity-30" />
|
||||
|
||||
{/* Level 3 */}
|
||||
<select
|
||||
value={selected3 || ""}
|
||||
onChange={(e) => updateFilter({ code3: e.target.value || null })}
|
||||
disabled={!selected2}
|
||||
className="apple-input text-[12px] h-8 min-w-[140px] disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<option value="">Toutes les Familles</option>
|
||||
{l3Options.map((opt: any) => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{(selected1 || selected2 || selected3) && (
|
||||
<button
|
||||
onClick={() => updateFilter({ code1: null, code2: null, code3: null })}
|
||||
className="p-1.5 hover:bg-rose-500/10 rounded-lg text-rose-500 transition-colors"
|
||||
title="Réinitialiser la nomenclature"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user