mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-24 07:28:10 +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
@@ -1,20 +1,32 @@
|
|||||||
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions";
|
import { getAvailableNomenclature, 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";
|
||||||
|
|
||||||
interface GridPageProps {
|
interface GridPageProps {
|
||||||
searchParams: Promise<{ fournisseur?: string; magasin?: string }>;
|
searchParams: Promise<{
|
||||||
|
fournisseur?: string;
|
||||||
|
magasin?: string;
|
||||||
|
code1?: string;
|
||||||
|
code2?: string;
|
||||||
|
code3?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GridPage({ searchParams }: GridPageProps) {
|
export default async function GridPage({ searchParams }: GridPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const codeFournisseur = params.fournisseur;
|
const codeFournisseur = params.fournisseur;
|
||||||
const magasin = params.magasin ?? "TOTAL";
|
const magasin = params.magasin ?? "TOTAL";
|
||||||
|
const filters = {
|
||||||
|
code1: params.code1 || null,
|
||||||
|
code2: params.code2 || null,
|
||||||
|
code3: params.code3 || null,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Fetch available suppliers & stores
|
// 1. Fetch available suppliers, stores & nomenclature hierarchy
|
||||||
const [fournisseurs, magasins] = await Promise.all([
|
const [fournisseurs, magasins, nomenclature] = 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
|
||||||
@@ -23,8 +35,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load real product data for the selected supplier
|
// 3. Load real product data for the selected supplier
|
||||||
const rows = await getGridData(codeFournisseur, magasin);
|
const rows = await getGridData(codeFournisseur, magasin, filters);
|
||||||
const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur);
|
const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridClient
|
<GridClient
|
||||||
@@ -34,6 +46,7 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
|||||||
fournisseurs={fournisseurs}
|
fournisseurs={fournisseurs}
|
||||||
magasins={magasins}
|
magasins={magasins}
|
||||||
magasin={magasin}
|
magasin={magasin}
|
||||||
|
nomenclature={nomenclature}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,10 @@ export const ventesProduits = pgTable("ventes_produits", {
|
|||||||
|
|
||||||
// Gamme & Nomenclature
|
// Gamme & Nomenclature
|
||||||
codeGamme: varchar("code_gamme", { length: 20 }),
|
codeGamme: varchar("code_gamme", { length: 20 }),
|
||||||
|
code1: varchar("code1", { length: 20 }),
|
||||||
|
libelleNiveau1: varchar("libelle_niveau1", { length: 500 }),
|
||||||
|
code2: varchar("code2", { length: 20 }),
|
||||||
|
libelleNiveau2: varchar("libelle_niveau2", { length: 500 }),
|
||||||
code3: varchar("code3", { length: 20 }),
|
code3: varchar("code3", { length: 20 }),
|
||||||
libelle3: varchar("libelle3", { length: 500 }),
|
libelle3: varchar("libelle3", { length: 500 }),
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { ventesProduits } from "@/db/schema";
|
import { ventesProduits } from "@/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { getProductRows } from "./api/get-product-rows";
|
import { getProductRows } from "./api/get-product-rows";
|
||||||
import type { ProductRow } from "@/types/grid";
|
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of all unique suppliers from the database.
|
* Get the list of all unique suppliers from the database.
|
||||||
@@ -51,9 +52,63 @@ export async function getMagasins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of all unique nomenclature levels available in the database.
|
||||||
|
*/
|
||||||
|
export async function getAvailableNomenclature() {
|
||||||
|
try {
|
||||||
|
const results = await db
|
||||||
|
.select({
|
||||||
|
code3: ventesProduits.code3,
|
||||||
|
libelle3: ventesProduits.libelle3,
|
||||||
|
})
|
||||||
|
.from(ventesProduits)
|
||||||
|
.where(sql`${ventesProduits.code3} IS NOT NULL`)
|
||||||
|
.groupBy(ventesProduits.code3, ventesProduits.libelle3);
|
||||||
|
|
||||||
|
const hierarchy: Record<string, { label: string, children: Record<string, { label: string, children: { code: string, label: string }[] }> }> = {};
|
||||||
|
|
||||||
|
// Mapping basique des libellés Niv 1 et 2
|
||||||
|
const L1_LABELS: Record<string, string> = {
|
||||||
|
"30": "ÉPICERIE",
|
||||||
|
"31": "CRÉMERIE / FRAIS",
|
||||||
|
"32": "LIQUIDES",
|
||||||
|
"33": "DPH",
|
||||||
|
"34": "BAZAR",
|
||||||
|
"35": "TEXTILE",
|
||||||
|
};
|
||||||
|
|
||||||
|
results.forEach(r => {
|
||||||
|
const c3 = r.code3 || "";
|
||||||
|
const l3 = r.libelle3 || "";
|
||||||
|
if (c3.length < 2) return;
|
||||||
|
|
||||||
|
const c1 = c3.slice(0, 2);
|
||||||
|
const c2 = c3.slice(0, 4);
|
||||||
|
|
||||||
|
if (!hierarchy[c1]) {
|
||||||
|
hierarchy[c1] = { label: L1_LABELS[c1] || `Niveau 1 (${c1})`, children: {} };
|
||||||
|
}
|
||||||
|
if (!hierarchy[c1].children[c2]) {
|
||||||
|
hierarchy[c1].children[c2] = { label: `Niveau 2 (${c2})`, children: [] };
|
||||||
|
}
|
||||||
|
hierarchy[c1].children[c2].children.push({ code: c3, label: l3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return hierarchy;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching nomenclature:", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get real product data for a specific supplier and store.
|
* Get real product data for a specific supplier and store.
|
||||||
*/
|
*/
|
||||||
export async function getGridData(codeFournisseur: string, magasin: string = "TOTAL"): Promise<ProductRow[]> {
|
export async function getGridData(
|
||||||
return getProductRows({ codeFournisseur, magasin });
|
codeFournisseur: string,
|
||||||
|
magasin: string = "TOTAL",
|
||||||
|
filters?: Partial<GridFilters>
|
||||||
|
): Promise<ProductRow[]> {
|
||||||
|
return getProductRows({ codeFournisseur, magasin, filters });
|
||||||
}
|
}
|
||||||
@@ -2,37 +2,48 @@
|
|||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { ventesProduits } from "@/db/schema";
|
import { ventesProduits } from "@/db/schema";
|
||||||
import { eq, and, ne } from "drizzle-orm";
|
import { eq, and, ne, like } from "drizzle-orm";
|
||||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
||||||
import { computeProductScores } from "@/lib/score-engine";
|
import { computeProductScores } from "@/lib/score-engine";
|
||||||
|
|
||||||
interface GetProductRowsInput {
|
interface GetProductRowsInput {
|
||||||
codeFournisseur: string;
|
codeFournisseur: string;
|
||||||
magasin?: string;
|
magasin?: string;
|
||||||
|
filters?: Partial<GridFilters>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
const { codeFournisseur, magasin = "TOTAL", filters } = input;
|
||||||
|
|
||||||
// Calculer les 12 derniers mois COMPLETS (excluant le mois en cours)
|
// ... (allowedPeriods calculation remains the same)
|
||||||
const allowedPeriods = new Set<string>();
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const allowedPeriods = new Set<string>();
|
||||||
for (let i = 12; i >= 1; i--) {
|
for (let i = 12; i >= 1; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all rows for this supplier (excluding the synthetic TOTAL period)
|
// Build conditions
|
||||||
|
const conditions = [
|
||||||
|
eq(ventesProduits.codeFournisseur, codeFournisseur),
|
||||||
|
ne(ventesProduits.periode, "TOTAL"),
|
||||||
|
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hierarchical filtering
|
||||||
|
if (filters?.code3) {
|
||||||
|
conditions.push(eq(ventesProduits.code3, filters.code3));
|
||||||
|
} else if (filters?.code2) {
|
||||||
|
conditions.push(like(ventesProduits.code3, `${filters.code2}%`));
|
||||||
|
} else if (filters?.code1) {
|
||||||
|
conditions.push(like(ventesProduits.code3, `${filters.code1}%`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rows for this supplier
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(ventesProduits)
|
.from(ventesProduits)
|
||||||
.where(
|
.where(and(...conditions));
|
||||||
and(
|
|
||||||
eq(ventesProduits.codeFournisseur, codeFournisseur),
|
|
||||||
ne(ventesProduits.periode, "TOTAL"),
|
|
||||||
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group by codein and aggregate monthly sales + track store participation
|
// Group by codein and aggregate monthly sales + track store participation
|
||||||
const productMap = new Map<string, ProductRow>();
|
const productMap = new Map<string, ProductRow>();
|
||||||
@@ -69,6 +80,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
|||||||
libelle1: row.libelle1 ?? "",
|
libelle1: row.libelle1 ?? "",
|
||||||
gtin: row.gtin ?? "",
|
gtin: row.gtin ?? "",
|
||||||
reference: row.reference ?? "",
|
reference: row.reference ?? "",
|
||||||
|
code1: (row.code3 ?? "").slice(0, 2),
|
||||||
|
libelleNiveau1: "Niv. 1", // À mapper plus tard
|
||||||
|
code2: (row.code3 ?? "").slice(0, 4),
|
||||||
|
libelleNiveau2: "Niv. 2", // À mapper plus tard
|
||||||
code3: row.code3 ?? "",
|
code3: row.code3 ?? "",
|
||||||
libelle3: row.libelle3 ?? "",
|
libelle3: row.libelle3 ?? "",
|
||||||
codeGamme: (row.codeGamme as GammeCode | null),
|
codeGamme: (row.codeGamme as GammeCode | null),
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ 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 }: GridClientProps) {
|
export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins, magasin, nomenclature }: GridClientProps) {
|
||||||
const setRows = useGridStore((s) => s.setRows);
|
const setRows = useGridStore((s) => s.setRows);
|
||||||
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
|
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
|
||||||
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
|
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
|
||||||
@@ -98,7 +99,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} nomenclature={nomenclature} />
|
||||||
|
|
||||||
{/* Bulk toolbar (contextual) */}
|
{/* Bulk toolbar (contextual) */}
|
||||||
<BulkActionToolbar
|
<BulkActionToolbar
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
|
|||||||
import { SupplierCombobox } from "./supplier-combobox";
|
import { SupplierCombobox } from "./supplier-combobox";
|
||||||
import { StoreCombobox } from "./store-combobox";
|
import { StoreCombobox } from "./store-combobox";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
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 }: GridFilterBarProps) {
|
export function GridFilterBar({ fournisseurs, magasins, nomenclature }: GridFilterBarProps) {
|
||||||
const { filters, setFilter } = useGridStore();
|
const { filters, setFilter } = useGridStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -107,20 +109,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nomenclature filter */}
|
{/* Nomenclature filter */}
|
||||||
<div className="relative">
|
<NomenclatureFilter hierarchy={nomenclature} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
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)}`;
|
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 {
|
interface HeatmapGridProps {
|
||||||
onSelectionChange?: (codeins: string[]) => void;
|
onSelectionChange?: (codeins: string[]) => void;
|
||||||
}
|
}
|
||||||
@@ -121,7 +138,37 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
accessorKey: "codein",
|
accessorKey: "codein",
|
||||||
header: "Code",
|
header: "Code",
|
||||||
size: 90,
|
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",
|
accessorKey: "libelle1",
|
||||||
@@ -133,21 +180,23 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
{row.original.libelle1}
|
{row.original.libelle1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
{row.original.workingStores.map((magasin) => (
|
{row.original.workingStores.map((magasin) => {
|
||||||
<div
|
const colors = getStoreColor(magasin);
|
||||||
key={magasin}
|
return (
|
||||||
title={`Travaillé par : ${magasin}`}
|
<div
|
||||||
className="w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-black border"
|
key={magasin}
|
||||||
style={{
|
title={`Travaillé par : ${magasin}`}
|
||||||
background: "var(--bg-elevated)",
|
className="w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-black border shadow-sm"
|
||||||
borderColor: "var(--border-strong)",
|
style={{
|
||||||
color: "var(--text-secondary)",
|
background: colors.bg,
|
||||||
boxShadow: "var(--shadow-xs)"
|
borderColor: colors.border,
|
||||||
}}
|
color: colors.text,
|
||||||
>
|
}}
|
||||||
{magasin.charAt(0).toUpperCase()}
|
>
|
||||||
</div>
|
{magasin.charAt(0).toUpperCase()}
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ export const useGridStore = create<GridState>()(
|
|||||||
filters: {
|
filters: {
|
||||||
magasin: null,
|
magasin: null,
|
||||||
codeFournisseur: null,
|
codeFournisseur: null,
|
||||||
|
code1: null,
|
||||||
|
code2: null,
|
||||||
code3: null,
|
code3: null,
|
||||||
codeGamme: null,
|
codeGamme: null,
|
||||||
search: "",
|
search: "",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export interface VenteProduit {
|
|||||||
gtin: string | null;
|
gtin: string | null;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
codeGamme: GammeCode | null;
|
codeGamme: GammeCode | null;
|
||||||
|
code1: string | null;
|
||||||
|
libelleNiveau1: string | null;
|
||||||
|
code2: string | null;
|
||||||
|
libelleNiveau2: string | null;
|
||||||
code3: string | null;
|
code3: string | null;
|
||||||
libelle3: string | null;
|
libelle3: string | null;
|
||||||
magasin: string;
|
magasin: string;
|
||||||
@@ -36,6 +40,10 @@ export interface ProductRow {
|
|||||||
libelle1: string;
|
libelle1: string;
|
||||||
gtin: string;
|
gtin: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
code1: string;
|
||||||
|
libelleNiveau1: string;
|
||||||
|
code2: string;
|
||||||
|
libelleNiveau2: string;
|
||||||
code3: string;
|
code3: string;
|
||||||
libelle3: string;
|
libelle3: string;
|
||||||
codeGamme: GammeCode | null;
|
codeGamme: GammeCode | null;
|
||||||
@@ -66,6 +74,8 @@ export interface GridSummary {
|
|||||||
export interface GridFilters {
|
export interface GridFilters {
|
||||||
magasin: string | null;
|
magasin: string | null;
|
||||||
codeFournisseur: string | null;
|
codeFournisseur: string | null;
|
||||||
|
code1: string | null;
|
||||||
|
code2: string | null;
|
||||||
code3: string | null;
|
code3: string | null;
|
||||||
codeGamme: GammeCode | null;
|
codeGamme: GammeCode | null;
|
||||||
search: string;
|
search: string;
|
||||||
|
|||||||
Reference in new issue
Block a user