From 02a382e815ce88005dcd90d7bb5391c53c3b310f Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 10:41:35 +0100 Subject: [PATCH 01/11] feat: implement virtualized HeatmapGrid component for efficient product data display and management. --- package-lock.json | 1 + package.json | 1 + src/features/grid/components/heatmap-grid.tsx | 50 +++++++++++++++++-- task.md | 30 ++++------- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88ae9de..61b9e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "collect-flow", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", diff --git a/package.json b/package.json index 53a2440..7d77914 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "docker:build": "docker-compose up -d --build" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 6fbb62f..ff02eae 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -12,7 +12,13 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; 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"; @@ -170,6 +176,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const { rows, filters, displayDensity } = useGridStore(); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState({}); + const [columnVisibility, setColumnVisibility] = useState({}); const [isMounted, setIsMounted] = useState(false); const tableContainerRef = useRef(null); @@ -413,9 +420,10 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const table = useReactTable({ data: rows, columns, - state: { sorting, globalFilter: filters.search, rowSelection }, + state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility }, onSortingChange: setSorting, onRowSelectionChange: handleRowSelectionChange, + onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -456,13 +464,49 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { return (
+
+ + + + + + {table + .getAllLeafColumns() + .filter((column: any) => column.getCanHide()) + .map((column: any) => { + return ( + column.toggleVisibility(!!value)} + onSelect={(e: React.SyntheticEvent) => e.preventDefault()} + > + {typeof column.columnDef.header === 'string' + ? column.columnDef.header + : column.id.startsWith('month_') + ? formatMonthLabel(column.id.replace('month_', '')) + : column.id} + + ); + })} + + +
+ Date: Fri, 6 Mar 2026 11:04:14 +0100 Subject: [PATCH 02/11] feat: implement new DropdownMenu UI component and create tsc-errors.txt to log existing TypeScript compilation issues. --- src/components/ui/dropdown-menu.tsx | 257 ++++++++++++++++++ src/features/grid/components/heatmap-grid.tsx | 2 +- verify-fix.ts | 27 -- verify-parser.ts | 55 ---- 4 files changed, 258 insertions(+), 83 deletions(-) create mode 100644 src/components/ui/dropdown-menu.tsx delete mode 100644 verify-fix.ts delete mode 100644 verify-parser.ts diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ae1fcf6 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index ff02eae..29e9a49 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -493,7 +493,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { className="capitalize text-xs cursor-pointer" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} - onSelect={(e: React.SyntheticEvent) => e.preventDefault()} + onSelect={(e: Event) => e.preventDefault()} > {typeof column.columnDef.header === 'string' ? column.columnDef.header diff --git a/verify-fix.ts b/verify-fix.ts deleted file mode 100644 index df94216..0000000 --- a/verify-fix.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import { ScoringEngine } from "../src/features/ai-copilot/business/scoring-engine"; -import { ProductAnalysisInput } from "../src/features/ai-copilot/models/ai-analysis.types"; - -const mockProducts: ProductAnalysisInput[] = [ - { codein: "PROD1", libelle1: "Nouveau Récent", regularityScore: 1, inactivityMonths: 0, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} }, - { codein: "PROD2", libelle1: "Nouveau Ancien", regularityScore: 1, inactivityMonths: 6, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} }, - { codein: "PROD3", libelle1: "Ancien Actif", regularityScore: 12, inactivityMonths: 0, totalCa: 1000, totalQuantite: 100, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} }, - { codein: "PROD4", libelle1: "Nouveau Limite", regularityScore: 2, inactivityMonths: 1, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} }, - { codein: "PROD5", libelle1: "Nouveau Hors Limite", regularityScore: 2, inactivityMonths: 2, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} }, -]; - -console.log("--- Testing isRecent Logic ---"); - -mockProducts.forEach(p => { - const result = ScoringEngine.analyzeRayon(p, mockProducts); - console.log(`Product: ${p.libelle1} (reg: ${p.regularityScore}, inact: ${p.inactivityMonths})`); - console.log(` isRecent: ${result.decision.isRecent}`); - console.log(` Recommendation: ${result.decision.recommendation}`); -}); - -// Expectations: -// PROD1: true (1 < 3 && 0 <= 1) -// PROD2: false (1 < 3 && 6 > 1) -> Recommendation should NOT be protected (unless score high) -// PROD3: false (12 >= 3) -// PROD4: true (2 < 3 && 1 <= 1) -// PROD5: false (2 < 3 && 2 > 1) diff --git a/verify-parser.ts b/verify-parser.ts deleted file mode 100644 index f6e7299..0000000 --- a/verify-parser.ts +++ /dev/null @@ -1,55 +0,0 @@ - -import { AnalysisEngine } from "./src/features/ai-copilot/business/analysis-engine"; - -console.log("--- Testing AI Parser Logic for B/C ---"); - -function simulateParse(content: string, simulateRuleResponse = false) { - let reco: "A" | "B" | "C" | "Z" | null = null; - let ruleApplies = simulateRuleResponse; - - try { - const jsonMatch = content.match(/\{[\s\S]*\}/); - const jsonText = jsonMatch ? jsonMatch[0] : content; - const parsed = JSON.parse(jsonText); - - reco = parsed.recommendation as "A" | "B" | "C" | "Z"; - ruleApplies = parsed.rule_applies === true; - - if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; - - if (!reco || !["A", "B", "C", "Z"].includes(reco)) { - reco = AnalysisEngine.extractRecommendation(parsed.justification || "") || "A"; - if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; - } - } catch (e) { - reco = AnalysisEngine.extractRecommendation(content); - if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A"; - } - return reco; -} - -const tests = [ - { name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" }, - { name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" }, - { name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" }, - { name: "Invalid Rule C (hallucinated)", json: '{"rule_applies": false, "recommendation": "C", "justification": "blabla"}', expected: "A" }, - { name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" }, - { name: "Valid Extraction B", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "B" }, - { name: "Invalid Extraction B", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "A" } -]; - -let allPassed = true; -tests.forEach(t => { - const res = simulateParse(t.json); - const pass = res === t.expected; - console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`); - if (!pass) allPassed = false; -}); - -if (allPassed) { - console.log("All parsing tests passed."); - process.exit(0); -} else { - console.log("Some tests failed."); - process.exit(1); -} From eb5bceb5bfcc04d48d0fb30483b8d26b2f9dece8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 11:14:56 +0100 Subject: [PATCH 03/11] feat: introduce DropdownMenu UI component and create HeatmapGrid file. --- src/components/ui/dropdown-menu.tsx | 20 +++++------ src/features/grid/components/heatmap-grid.tsx | 34 +++++++++++++++++-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ae1fcf6..61060a7 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -42,7 +42,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-2 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} @@ -74,7 +74,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", + "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} {...props} @@ -92,15 +92,15 @@ function DropdownMenuCheckboxItem({ - + - + {children} @@ -128,14 +128,14 @@ function DropdownMenuRadioItem({ - + - + {children} @@ -211,13 +211,13 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} > {children} - + ) } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 29e9a49..d2de87f 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -248,7 +248,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { }, { accessorKey: "codein", - header: "Code", + header: "Code interne", size: 90, cell: ({ getValue }) => { const value = getValue(); @@ -266,7 +266,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
{value} @@ -282,6 +282,32 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { ); }, }, + { + accessorKey: "reference", + header: "Référence", + size: 110, + cell: ({ getValue }) => { + const val = getValue(); + return ( + + {val || "-"} + + ); + }, + }, + { + accessorKey: "gtin", + header: "EAN / GTIN", + size: 120, + cell: ({ getValue }) => { + const val = getValue(); + return ( + + {val || "-"} + + ); + }, + }, { accessorKey: "libelle1", header: "Désignation", @@ -432,7 +458,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const search = String(filterValue).toLowerCase(); const libelle = String(row.original.libelle1 || "").toLowerCase(); const code = String(row.original.codein || "").toLowerCase(); - return libelle.includes(search) || code.includes(search); + const reference = String(row.original.reference || "").toLowerCase(); + const gtin = String(row.original.gtin || "").toLowerCase(); + return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search); }, }); From 57449ec60338f0e9c306be83a5260a663f5f28dd Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 11:18:35 +0100 Subject: [PATCH 04/11] feat: introduce heatmap grid component for displaying and managing product data with virtualization and performance optimizations. --- src/features/grid/components/heatmap-grid.tsx | 152 +++++++++--------- 1 file changed, 77 insertions(+), 75 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index d2de87f..437cebe 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -490,27 +490,19 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { } return ( -
-
+
+
- + {table .getAllLeafColumns() .filter((column: any) => column.getCanHide()) @@ -518,7 +510,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { return ( column.toggleVisibility(!!value)} onSelect={(e: Event) => e.preventDefault()} @@ -535,68 +527,78 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3"; - const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme"; - const size = header.getSize(); - return ( - - ); - })} - - ))} - - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = tableRows[virtualRow.index]; - const isSelected = row.getIsSelected(); +
+
-
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getCanSort() && ( -
- {header.column.getIsSorted() === "asc" ? - : header.column.getIsSorted() === "desc" ? - : - } -
- )} -
-
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3"; + const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme"; + const size = header.getSize(); + return ( + + ); + })} + + ))} + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = tableRows[virtualRow.index]; + const isSelected = row.getIsSelected(); - return ( -
- -
- ); - })} - -
+
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( +
+ {header.column.getIsSorted() === "asc" ? + : header.column.getIsSorted() === "desc" ? + : + } +
+ )} +
+
+ return ( +
+ +
+ ); + })} + + +
); } From 9f827c755490d1936b06635a405e7276819380fc Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 11:23:32 +0100 Subject: [PATCH 05/11] feat: add new DropdownMenu component using Radix UI primitives and Tailwind CSS for styling. --- src/components/ui/dropdown-menu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 61060a7..bbc7d47 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -42,7 +42,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-2 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-2 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} @@ -230,7 +230,7 @@ function DropdownMenuSubContent({ Date: Fri, 6 Mar 2026 15:03:25 +0100 Subject: [PATCH 06/11] feat: Implement virtualized product heatmap grid with data fetching and client integration. --- src/features/grid/api/get-product-rows.ts | 2 +- src/features/grid/components/grid-client.tsx | 1 + src/features/grid/components/heatmap-grid.tsx | 18 ++++++++++++------ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index edc0b34..9f5888e 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -149,7 +149,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise= 3) { + if (periods.size >= 1) { workingStores.push(magasin); } } diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 1a42ad3..c37c152 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -91,6 +91,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins

+
{hasDrafts && (
+ , + portalContainer + )}
Date: Fri, 6 Mar 2026 15:08:35 +0100 Subject: [PATCH 07/11] feat: introduce `HeatmapGrid` component for virtualized product data display with gamme selection and performance optimizations. --- src/features/grid/components/heatmap-grid.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 1b2011a..9bcc334 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -119,11 +119,13 @@ interface VirtualRowProps { row: Row; rowHeight: number; isSelected: boolean; + columnVisibility: Record; } -const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected }: VirtualRowProps) => { +const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility }: VirtualRowProps) => { // Uniquement la ligne concernée écoute son propre changement pour l'effet visuel const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme); + void columnVisibility; // Force re-render via React.memo when visibility changes return (
); From 1ba507da94dd103576392e6877aee07c15f02f96 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 15:15:11 +0100 Subject: [PATCH 08/11] feat: implement virtualized heatmap grid component with a Zustand store for managing grid data, filters, and draft product gamme changes. --- src/features/grid/components/heatmap-grid.tsx | 2 +- src/features/grid/store/use-grid-store.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 9bcc334..bd5eaaa 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -179,7 +179,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const { rows, filters, displayDensity } = useGridStore(); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState({}); - const [columnVisibility, setColumnVisibility] = useState({}); + const { columnVisibility, setColumnVisibility } = useGridStore(); const [isMounted, setIsMounted] = useState(false); const [portalContainer, setPortalContainer] = useState(null); const tableContainerRef = useRef(null); diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index b06b47d..34bfcf1 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -14,6 +14,8 @@ interface GridState { displayDensity: "compact" | "normal" | "comfortable"; /** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */ activeGridQuery: string; + /** Persisted column visibility state */ + columnVisibility: Record; // Actions setRows: (rows: ProductRow[]) => void; @@ -26,6 +28,7 @@ interface GridState { setActiveGridQuery: (query: string) => void; restoreSnapshot: (changes: Record) => void; batchSetDraftGamme: (changes: Record) => void; + setColumnVisibility: (updater: Record | ((old: Record) => Record)) => void; } function computeSummary(rows: ProductRow[], drafts: Record): GridSummary { @@ -67,6 +70,7 @@ export const useGridStore = create()( }, displayDensity: "normal", activeGridQuery: "", + columnVisibility: {}, setRows: (rows) => { set({ rows, summary: computeSummary(rows, get().draftChanges) }); @@ -124,15 +128,22 @@ export const useGridStore = create()( set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) }); }, + + setColumnVisibility: (updater) => { + set((state) => ({ + columnVisibility: typeof updater === 'function' ? updater(state.columnVisibility) : updater + })); + }, }), { name: "collectflow-grid-storage", - // Only persist filters, display density, drafts, and active grid query + // Only persist filters, display density, drafts, active grid query, and column visibility partialize: (state) => ({ filters: state.filters, displayDensity: state.displayDensity, draftChanges: state.draftChanges, activeGridQuery: state.activeGridQuery, + columnVisibility: state.columnVisibility, }), } ) From cdc310223c831d1b214e431ed577f230514011ab Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 15:27:18 +0100 Subject: [PATCH 09/11] feat: implement virtualized heatmap grid component using TanStack Table and a new Zustand store for product data. --- src/features/grid/components/heatmap-grid.tsx | 22 +++++++++++++++---- src/features/grid/store/use-grid-store.ts | 12 +++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index bd5eaaa..2a43bb0 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -120,12 +120,14 @@ interface VirtualRowProps { rowHeight: number; isSelected: boolean; columnVisibility: Record; + columnSizing: Record; } -const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility }: VirtualRowProps) => { +const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility, columnSizing }: VirtualRowProps) => { // Uniquement la ligne concernée écoute son propre changement pour l'effet visuel const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme); void columnVisibility; // Force re-render via React.memo when visibility changes + void columnSizing; // Force re-render via React.memo when resizing columns return ( ([]); const [rowSelection, setRowSelection] = useState({}); - const { columnVisibility, setColumnVisibility } = useGridStore(); + const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore(); const [isMounted, setIsMounted] = useState(false); const [portalContainer, setPortalContainer] = useState(null); const tableContainerRef = useRef(null); @@ -452,10 +454,12 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { const table = useReactTable({ data: rows, columns, - state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility }, + state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing }, onSortingChange: setSorting, onRowSelectionChange: handleRowSelectionChange, onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, + columnResizeMode: "onChange", getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -560,7 +564,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { return ( )} + {/* Handle de redimensionnement de la colonne */} + {header.column.getCanResize() && ( +
e.stopPropagation()} + className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`} + /> + )} ); })} @@ -601,6 +614,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) { rowHeight={rowHeight} isSelected={isSelected} columnVisibility={columnVisibility} + columnSizing={columnSizing} />
); diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 34bfcf1..43ccdfa 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -16,6 +16,8 @@ interface GridState { activeGridQuery: string; /** Persisted column visibility state */ columnVisibility: Record; + /** Persisted column sizing state */ + columnSizing: Record; // Actions setRows: (rows: ProductRow[]) => void; @@ -29,6 +31,7 @@ interface GridState { restoreSnapshot: (changes: Record) => void; batchSetDraftGamme: (changes: Record) => void; setColumnVisibility: (updater: Record | ((old: Record) => Record)) => void; + setColumnSizing: (updater: Record | ((old: Record) => Record)) => void; } function computeSummary(rows: ProductRow[], drafts: Record): GridSummary { @@ -71,6 +74,7 @@ export const useGridStore = create()( displayDensity: "normal", activeGridQuery: "", columnVisibility: {}, + columnSizing: {}, setRows: (rows) => { set({ rows, summary: computeSummary(rows, get().draftChanges) }); @@ -134,16 +138,22 @@ export const useGridStore = create()( columnVisibility: typeof updater === 'function' ? updater(state.columnVisibility) : updater })); }, + setColumnSizing: (updater) => { + set((state) => ({ + columnSizing: typeof updater === 'function' ? updater(state.columnSizing) : updater + })); + }, }), { name: "collectflow-grid-storage", - // Only persist filters, display density, drafts, active grid query, and column visibility + // Only persist filters, display density, drafts, active grid query, and column visibility/sizing partialize: (state) => ({ filters: state.filters, displayDensity: state.displayDensity, draftChanges: state.draftChanges, activeGridQuery: state.activeGridQuery, columnVisibility: state.columnVisibility, + columnSizing: state.columnSizing, }), } ) From 34e8f4a767c7dead93ded9312f36ddb22acd7f5c Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 15:53:24 +0100 Subject: [PATCH 10/11] feat: add bulk action toolbar component for applying gamme codes to selected items. --- src/features/grid/components/bulk-action-toolbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/grid/components/bulk-action-toolbar.tsx b/src/features/grid/components/bulk-action-toolbar.tsx index 7cfd43c..6929e65 100644 --- a/src/features/grid/components/bulk-action-toolbar.tsx +++ b/src/features/grid/components/bulk-action-toolbar.tsx @@ -17,12 +17,16 @@ interface BulkActionToolbarProps { } export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) { - const { setDraftGamme } = useGridStore(); + const { batchSetDraftGamme } = useGridStore(); if (selectedCodeins.length === 0) return null; const applyBulk = (code: GammeCode) => { - selectedCodeins.forEach((codein) => setDraftGamme(codein, code)); + const changes = selectedCodeins.reduce((acc, codein) => { + acc[codein] = code; + return acc; + }, {} as Record); + batchSetDraftGamme(changes); onClearSelection(); }; From 54a4e7f4d1ee0366ff8cc85044dd405d3e599fd0 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Mar 2026 16:00:42 +0100 Subject: [PATCH 11/11] feat: add bulk AI analyzer component for product scoring and context profiling. --- .../ai-copilot/business/context-profiler.ts | 199 +++++++++++------- .../grid/components/bulk-ai-analyzer.tsx | 5 +- 2 files changed, 129 insertions(+), 75 deletions(-) diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index 0fe047f..286804c 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -28,6 +28,26 @@ const MIN_RAYON_SIZE = 6; export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH"; +export interface ContextProfilerCache { + allCaPerStoreSorted: number[]; + allQtyPerStoreSorted: number[]; + allMargeValuesSorted: number[]; + + totalCaFournisseur: number; + totalQtyFournisseur: number; + + top20CaThreshold: number; + top20QtyThreshold: number; + + medianQtyPerStore: number; + medianMarge: number; + + marge40: number; + qty60PerStore: number; + + rayonStats: Map; +} + export interface ProductContextProfile { // Identité codein: string; @@ -108,28 +128,41 @@ export interface ProductContextProfile { // Helpers statistiques (purs) // --------------------------------------------------------------------------- -function computePercentile(value: number, distribution: number[]): number { - if (distribution.length <= 1) return 100; - const sorted = [...distribution].sort((a, b) => a - b); - const first = sorted.indexOf(value); - const last = sorted.lastIndexOf(value); - if (first === -1) return 0; - const avgRank = (first + last) / 2; - return Math.round((avgRank / (sorted.length - 1)) * 100); +function computePercentileFromSorted(value: number, sortedDistribution: number[]): number { + if (sortedDistribution.length <= 1) return 100; + + let first = -1; + let last = -1; + + for (let i = 0; i < sortedDistribution.length; i++) { + const current = sortedDistribution[i]; + if (Math.abs(current - value) < 0.00001) { // Floating point protection + if (first === -1) first = i; + last = i; + } else if (current > value) { + if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100); + break; + } + } + + if (first !== -1) { + const avgRank = (first + last) / 2; + return Math.round((avgRank / (sortedDistribution.length - 1)) * 100); + } + + return 100; } -function computeMedian(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); +function computeMedianFromSorted(sorted: number[]): number { + if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } -function valueAtPercentile(values: number[], p: number): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); +function valueAtPercentileFromSorted(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1); return sorted[idx]; } @@ -151,69 +184,107 @@ function getRayonKey(p: ProductAnalysisInput): string { return p.libelleNiveau2 ?? "default"; } +const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1); +const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p); +const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p); +const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0; +const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0; + // --------------------------------------------------------------------------- // Profiler principal // --------------------------------------------------------------------------- export class ContextProfiler { + + /** + * Prépare un cache contextuel basé sur tous les produits pour éviter des tris + * répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)). + */ + static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache { + const allCaPerStore = allProds.map(normCa); + const allQtyPerStore = allProds.map(normQty); + const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); + + // On trie une seule et unique fois + const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b); + const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b); + const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b); + + const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0); + const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0); + + const rayonStats = new Map(); + allProds.forEach(p => { + const key = getRayonKey(p); + if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 }); + const s = rayonStats.get(key)!; + s.totalCa += getWeightedCa(p); + s.totalQty += getWeightedQty(p); + s.count += 1; + }); + + return { + allCaPerStoreSorted, + allQtyPerStoreSorted, + allMargeValuesSorted, + totalCaFournisseur, + totalQtyFournisseur, + top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80), + top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80), + medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted), + medianMarge: computeMedianFromSorted(allMargeValuesSorted), + marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40), + qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60), + rayonStats, + }; + } + /** * Génère le profil contextuel d'un produit au sein de son lot fournisseur. */ static buildProfile( target: ProductAnalysisInput, allProds: ProductAnalysisInput[], - scoring: ScoringResult + scoring: ScoringResult, + cache?: ContextProfilerCache ): ProductContextProfile { if (allProds.length === 0) { throw new Error("[ContextProfiler] Le lot de produits est vide."); } + // Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer, + // sinon le générer à la volée (mode single item) + const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds); + // --------------------------------------------------------------------------- // Normalisation par magasin — cœur de la v3 - // Raison : un produit en 2 magasins a mécaniquement 2x plus de CA/QTÉ - // qu'un produit identique en 1 magasin. Sans normalisation, les percentiles - // et le quadrant sont fausss par le réseau de distribution, pas la perf. // --------------------------------------------------------------------------- - const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1); - const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p); - const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p); - const targetStoreCount = getNormStoreCount(target); const targetCaPerStore = normCa(target); const targetQtyPerStore = normQty(target); - // 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau) - // weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2). - const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0; - const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0; - - const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0); - const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0); + // 1. Totaux fournisseur + const { totalCaFournisseur, totalQtyFournisseur } = ctx; // 2. Totaux du rayon (Niveau 2 de nomenclature) const targetRayonKey = getRayonKey(target); - const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey); - const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0); - const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0); + const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 }; + const totalCaRayon = rayonStat.totalCa; + const totalQtyRayon = rayonStat.totalQty; + const rayonSizeForSignals = rayonStat.count; - // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant - const allCaPerStore = allProds.map(normCa); - const allQtyPerStore = allProds.map(normQty); - const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); - - const pCa = computePercentile(targetCaPerStore, allCaPerStore); - const pQty = computePercentile(targetQtyPerStore, allQtyPerStore); - const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues); + // 3. Percentiles (0-100) — sur distribution PAR MAGASIN + const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted); + const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted); + const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted); const pComposite = scoring.compositeScore; - // 4. Tops 20% (sur valeurs PAR MAGASIN) - const top20CaThreshold = valueAtPercentile(allCaPerStore, 80); - const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80); - const isTop20Ca = targetCaPerStore >= top20CaThreshold; - const isTop20Qty = targetQtyPerStore >= top20QtyThreshold; + // 4. Tops 20% + const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold; + const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold; const isAboveMedianComposite = pComposite >= 50; - // 5. Poids pondérés (valeurs réseau projetées pour représentativité réaliste) + // 5. Poids pondérés const weightCaFournisseur = totalCaFournisseur > 0 ? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10 @@ -223,52 +294,32 @@ export class ContextProfiler { ? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10 : 0; - // Bug fix : un produit dans le top 30% de marge du lot apporte une valeur - // réelle en rentabilité. L'exclure du signal isLowContribution évite de le - // pénaliser uniquement parce que son volume/CA est faible (profil 💎 pur). - // Le seuil de faible contribution utilise désormais des percentiles plutôt - // que des valeurs absolues pour ne pas pénaliser les gros fournisseurs. const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70; - // 6. Signaux Trafic / Marge + Quadrant (sur valeurs PAR MAGASIN) - // Médianes calculées ici pour être disponibles dans les sections 6 ET 7. - const medianQtyPerStore = computeMedian(allQtyPerStore); - const medianMarge = computeMedian(allMargeValues); - - const rayonSizeForSignals = rayonProds.length; + // 6. Signaux Trafic / Marge + Quadrant const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; let isHighVolumeWithLowMargin = false; let isMargePure = false; - // Bug fix : isMargePure est désormais calculé sur tous les rayons, même petits. - // Raison : la marge absolue est une donnée fiable quelle que soit la taille du lot. - // Le signal isHighVolumeWithLowMargin (trafic) garde le seuil MIN_RAYON_SIZE car - // il nécessite une distribution volumique suffisante pour être statistiquement valide. { - const marge40 = valueAtPercentile(allMargeValues, 40); - const qty60PerStore = valueAtPercentile(allQtyPerStore, 60); - - // Signal trafic : nécessite un rayon suffisamment large if (signalsActive) { isHighVolumeWithLowMargin = - targetQtyPerStore >= qty60PerStore && - (target.tauxMarge ?? 0) < marge40; + targetQtyPerStore >= ctx.qty60PerStore && + (target.tauxMarge ?? 0) < ctx.marge40; } - // Signal marge pure : au-dessus de la médiane marge + volume faible. - // Utilise la médiane (P50) plutôt que P70 pour être robuste sur petits lots. isMargePure = - (target.tauxMarge ?? 0) > medianMarge && - targetQtyPerStore < medianQtyPerStore; + (target.tauxMarge ?? 0) > ctx.medianMarge && + targetQtyPerStore < ctx.medianQtyPerStore; } - // 7. Quadrant (basé sur les médianes PAR MAGASIN — déjà calculées en section 6) + // 7. Quadrant const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( targetQtyPerStore, target.tauxMarge ?? 0, - medianQtyPerStore, - medianMarge + ctx.medianQtyPerStore, + ctx.medianMarge ); // 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final) diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 401f7d7..69a55a2 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -150,6 +150,9 @@ export function BulkAiAnalyzer() { const scoringResults = new Map>(); const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; + // Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes + const contextCache = ContextProfiler.prepareCache(initialPayloads); + for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) { if (isCancelledRef.current) break; @@ -161,7 +164,7 @@ export function BulkAiAnalyzer() { let contextProfile: ProductAnalysisInput["contextProfile"]; try { - contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes); + contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes, contextCache); } catch (err) { console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err); contextProfile = undefined;