diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index bed3569..c20dd6f 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -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 { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; 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) { const params = await searchParams; const codeFournisseur = params.fournisseur; const magasin = params.magasin ?? "TOTAL"; + const filters = { + code1: params.code1 || null, + code2: params.code2 || null, + code3: params.code3 || null, + }; - // 1. Fetch available suppliers & stores - const [fournisseurs, magasins] = await Promise.all([ + // 1. Fetch available suppliers, stores & nomenclature hierarchy + const [fournisseurs, magasins, nomenclature] = await Promise.all([ getFournisseurs(), - getMagasins() + getMagasins(), + getAvailableNomenclature() ]); // 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 - const rows = await getGridData(codeFournisseur, magasin); - const selectedFournisseur = fournisseurs.find((f) => f.code === codeFournisseur); + const rows = await getGridData(codeFournisseur, magasin, filters); + const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur); return ( ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index f011245..489b0f2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,6 +14,10 @@ export const ventesProduits = pgTable("ventes_produits", { // Gamme & Nomenclature 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 }), libelle3: varchar("libelle3", { length: 500 }), diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts index f2f9939..4b4bc96 100644 --- a/src/features/grid/actions.ts +++ b/src/features/grid/actions.ts @@ -2,8 +2,9 @@ import { db } from "@/db"; import { ventesProduits } from "@/db/schema"; +import { sql } from "drizzle-orm"; 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. @@ -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 }> = {}; + + // Mapping basique des libellés Niv 1 et 2 + const L1_LABELS: Record = { + "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. */ -export async function getGridData(codeFournisseur: string, magasin: string = "TOTAL"): Promise { - return getProductRows({ codeFournisseur, magasin }); +export async function getGridData( + codeFournisseur: string, + magasin: string = "TOTAL", + filters?: Partial +): Promise { + return getProductRows({ codeFournisseur, magasin, filters }); } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 73063cd..167d8f8 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -2,37 +2,48 @@ import { db } from "@/db"; import { ventesProduits } from "@/db/schema"; -import { eq, and, ne } from "drizzle-orm"; -import type { ProductRow, GammeCode } from "@/types/grid"; +import { eq, and, ne, like } from "drizzle-orm"; +import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; import { computeProductScores } from "@/lib/score-engine"; interface GetProductRowsInput { codeFournisseur: string; magasin?: string; + filters?: Partial; } export async function getProductRows(input: GetProductRowsInput): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; + const { codeFournisseur, magasin = "TOTAL", filters } = input; - // Calculer les 12 derniers mois COMPLETS (excluant le mois en cours) - const allowedPeriods = new Set(); + // ... (allowedPeriods calculation remains the same) const now = new Date(); + const allowedPeriods = new Set(); for (let i = 12; i >= 1; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); 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 .select() .from(ventesProduits) - .where( - and( - eq(ventesProduits.codeFournisseur, codeFournisseur), - ne(ventesProduits.periode, "TOTAL"), - magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"), - ) - ); + .where(and(...conditions)); // Group by codein and aggregate monthly sales + track store participation const productMap = new Map(); @@ -69,6 +80,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise 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 {/* Filters */} - + {/* Bulk toolbar (contextual) */} {/* Nomenclature filter */} -
- - -
+ ); } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 9c2d03c..8164c84 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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 }) => {getValue()}, + cell: ({ getValue }) => { + const value = getValue(); + // 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 ( +
+ + {value} + +
+ {copied ? ( + + ) : ( + + )} +
+
+ ); + }, }, { accessorKey: "libelle1", @@ -133,21 +180,23 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { {row.original.libelle1}
- {row.original.workingStores.map((magasin) => ( -
- {magasin.charAt(0).toUpperCase()} -
- ))} + {row.original.workingStores.map((magasin) => { + const colors = getStoreColor(magasin); + return ( +
+ {magasin.charAt(0).toUpperCase()} +
+ ); + })}
), diff --git a/src/features/grid/components/nomenclature-filter.tsx b/src/features/grid/components/nomenclature-filter.tsx new file mode 100644 index 0000000..925df43 --- /dev/null +++ b/src/features/grid/components/nomenclature-filter.tsx @@ -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) => { + 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 ( +
+ {/* Level 1 */} + + + + + {/* Level 2 */} + + + + + {/* Level 3 */} + + + {(selected1 || selected2 || selected3) && ( + + )} +
+ ); +} diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 9647e4c..487f3e7 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -45,6 +45,8 @@ export const useGridStore = create()( filters: { magasin: null, codeFournisseur: null, + code1: null, + code2: null, code3: null, codeGamme: null, search: "", diff --git a/src/types/grid.ts b/src/types/grid.ts index e0f80bc..8121dc2 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -13,6 +13,10 @@ export interface VenteProduit { gtin: string | null; reference: string | null; codeGamme: GammeCode | null; + code1: string | null; + libelleNiveau1: string | null; + code2: string | null; + libelleNiveau2: string | null; code3: string | null; libelle3: string | null; magasin: string; @@ -36,6 +40,10 @@ export interface ProductRow { libelle1: string; gtin: string; reference: string; + code1: string; + libelleNiveau1: string; + code2: string; + libelleNiveau2: string; code3: string; libelle3: string; codeGamme: GammeCode | null; @@ -66,6 +74,8 @@ export interface GridSummary { export interface GridFilters { magasin: string | null; codeFournisseur: string | null; + code1: string | null; + code2: string | null; code3: string | null; codeGamme: GammeCode | null; search: string;