This commit is contained in:
Michael committed 2026-03-10 11:11:29 +01:00
commit 2a76745f29
13 files changed
+599 -253

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+257
View File
@@ -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<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
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 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}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
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 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}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-2 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex w-3.5 h-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="w-4 h-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex w-3.5 h-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="w-2 h-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
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 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}
<ChevronRightIcon className="ml-auto w-4 h-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-1 text-[var(--text-primary)] shadow-lg 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",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -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<string, { totalCa: number; totalQty: number; count: number }>;
}
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<string, { totalCa: number; totalQty: number; count: number }>();
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)
+1 -1
View File
@@ -149,7 +149,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const workingStores: string[] = [];
for (const [magasin, periods] of productStores.entries()) {
if (magasin === "TOTAL") continue;
if (periods.size >= 3) {
if (periods.size >= 1) {
workingStores.push(magasin);
}
}
@@ -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<string, GammeCode>);
batchSetDraftGamme(changes);
onClearSelection();
};
@@ -150,6 +150,9 @@ export function BulkAiAnalyzer() {
const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
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;
@@ -91,6 +91,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
</p>
</div>
<div className="flex items-center gap-3">
<div id="grid-toolbar-actions"></div>
<ExportDropdown nomFournisseur={nomFournisseur} />
{hasDrafts && (
<button
+170 -73
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import {
useReactTable,
getCoreRowModel,
@@ -12,7 +13,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";
@@ -112,11 +119,15 @@ interface VirtualRowProps {
row: Row<ProductRow>;
rowHeight: number;
isSelected: boolean;
columnVisibility: Record<string, boolean>;
columnSizing: Record<string, number>;
}
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected }: 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 (
<tr
@@ -170,7 +181,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
const { rows, filters, displayDensity } = useGridStore();
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
const [isMounted, setIsMounted] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
@@ -178,6 +191,8 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
useEffect(() => {
setIsMounted(true);
const container = document.getElementById('grid-toolbar-actions');
setPortalContainer(container);
}, []);
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
@@ -241,7 +256,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
},
{
accessorKey: "codein",
header: "Code",
header: "Code interne",
size: 90,
cell: ({ getValue }) => {
const value = getValue<string>();
@@ -259,7 +274,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
<div
onClick={handleCopy}
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
title="Copier le code"
title="Copier le code interne"
>
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
{value}
@@ -275,6 +290,32 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
);
},
},
{
accessorKey: "reference",
header: "Référence",
size: 110,
cell: ({ getValue }) => {
const val = getValue<string>();
return (
<span className="text-[12px] font-mono opacity-80" style={{ color: "var(--text-secondary)" }}>
{val || "-"}
</span>
);
},
},
{
accessorKey: "gtin",
header: "EAN / GTIN",
size: 120,
cell: ({ getValue }) => {
const val = getValue<string>();
return (
<span className="text-[12px] font-mono tracking-tight opacity-70" style={{ color: "var(--text-secondary)" }}>
{val || "-"}
</span>
);
},
},
{
accessorKey: "libelle1",
header: "Désignation",
@@ -413,9 +454,12 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
const table = useReactTable({
data: rows,
columns,
state: { sorting, globalFilter: filters.search, rowSelection },
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
onSortingChange: setSorting,
onRowSelectionChange: handleRowSelectionChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
columnResizeMode: "onChange",
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -424,7 +468,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);
},
});
@@ -437,7 +483,8 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
overscan: 20,
});
const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0);
const visibleColumns = table.getVisibleLeafColumns();
const totalWidth = visibleColumns.reduce((s, c) => s + ((c.columnDef as { size?: number }).size ?? 150), 0);
if (!isMounted) {
return (
@@ -454,77 +501,127 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
}
return (
<div
ref={tableContainerRef}
className="h-full w-full overflow-auto rounded-[12px] scroll-smooth"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
boxShadow: "var(--shadow-sm)"
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)"
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{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();
<div className="h-full w-full relative">
{portalContainer && createPortal(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold bg-[var(--action-secondary-bg)] text-[var(--action-secondary-text)] border border-[var(--border-strong)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-all shadow-sm apple-btn-secondary"
aria-label="Afficher/masquer les colonnes"
>
<SlidersHorizontal className="w-4 h-4" />
Colonnes
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[220px] max-h-[50vh] overflow-y-auto z-50">
{table
.getAllLeafColumns()
.filter((column: any) => column.getCanHide())
.map((column: any) => {
return (
<th
key={header.id}
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5"
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
}}
onClick={header.column.getToggleSortingHandler()}
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize text-xs cursor-pointer font-medium"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e: Event) => e.preventDefault()}
>
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<div className="shrink-0 opacity-40">
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
: <ChevronsUpDown className="w-3 h-3" />
}
</div>
)}
</div>
</th>
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: column.id.startsWith('month_')
? formatMonthLabel(column.id.replace('month_', ''))
: column.id}
</DropdownMenuCheckboxItem>
);
})}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const isSelected = row.getIsSelected();
</DropdownMenuContent>
</DropdownMenu>,
portalContainer
)}
return (
<div key={row.id} ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
<GridRow
virtualRow={virtualRow}
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
/>
</div>
);
})}
</tbody>
</table>
<div
ref={tableContainerRef}
className="h-full w-full overflow-auto rounded-[12px] scroll-smooth relative"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
boxShadow: "var(--shadow-sm)"
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)"
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{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 (
<th
key={header.id}
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5 relative group/header"
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-muted)",
justifyContent: isCenter ? "center" : "flex-start"
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<div className="shrink-0 opacity-40">
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
: <ChevronsUpDown className="w-3 h-3" />
}
</div>
)}
</div>
{/* Handle de redimensionnement de la colonne */}
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => 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" : ""}`}
/>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
const isSelected = row.getIsSelected();
return (
<div key={row.id} ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
<GridRow
virtualRow={virtualRow}
row={row}
rowHeight={rowHeight}
isSelected={isSelected}
columnVisibility={columnVisibility}
columnSizing={columnSizing}
/>
</div>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+22 -1
View File
@@ -14,6 +14,10 @@ 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<string, boolean>;
/** Persisted column sizing state */
columnSizing: Record<string, number>;
// Actions
setRows: (rows: ProductRow[]) => void;
@@ -26,6 +30,8 @@ interface GridState {
setActiveGridQuery: (query: string) => void;
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
batchSetDraftGamme: (changes: Record<string, GammeCode>) => void;
setColumnVisibility: (updater: Record<string, boolean> | ((old: Record<string, boolean>) => Record<string, boolean>)) => void;
setColumnSizing: (updater: Record<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void;
}
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
@@ -67,6 +73,8 @@ export const useGridStore = create<GridState>()(
},
displayDensity: "normal",
activeGridQuery: "",
columnVisibility: {},
columnSizing: {},
setRows: (rows) => {
set({ rows, summary: computeSummary(rows, get().draftChanges) });
@@ -124,15 +132,28 @@ export const useGridStore = create<GridState>()(
set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) });
},
setColumnVisibility: (updater) => {
set((state) => ({
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, and active grid query
// 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,
}),
}
)
+11 -19
View File
@@ -1,31 +1,23 @@
# Tâches en cours : Refonte du Moteur de Recommandation IA (Multi-Dimensionnel)
# Tâches en cours : Activation BMad Master et Party Mode
## Contexte
Mary (IA) donnait des recommandations basées sur le score brut sans analyse contextuelle. La refonte introduit un profilage statistique adaptatif (poids CA, poids QTÉ, marge, profil quadrant) avant de soumettre à l'IA.
L'utilisateur a activé le menu BMad Master puis a demandé à lancer le "Party Mode" (pm), un processus de discussion de groupe multi-agents.
## Focus Actuel
**Implémentation** : Création du `context-profiler.ts`, refonte du prompt `analysis-engine.ts`, injection dans `bulk-ai-analyzer.tsx`.
**Implémentation** : Récupérer le manifeste des agents et initialiser le Party Mode avec un message de bienvenue incluant l'introduction de quelques agents diversifiés.
## Master Plan
- [x] Diagnostic et analyse du pipeline existant
- [x] Rédaction du plan d'implémentation (validé par Michael)
- [x] Créer `context-profiler.ts` (profilage adaptatif, percentiles dynamiques)
- [x] Modifier `ai-analysis.types.ts` (ajout `contextProfile` + `codeNomenclatureN2`)
- [x] Refondre `analysis-engine.ts` (nouveau prompt Mary v3)
- [x] Modifier `bulk-ai-analyzer.tsx` (injection ContextProfiler + `r.code2`)
- [ ] Vérification TypeScript (`tsc --noEmit` en cours)
- [x] Refonte esthétique des paramètres (Boutons Glossy, Glassmorphism)
- [x] Créer le Walkthrough
- [ ] Tests manuels sur cas critiques (à faire par Michael)
- [x] Rôle assumé : BMad Master
- [x] Chargement de la configuration
- [x] Lancement de l'orchestration du workflow "Party Mode"
- [x] Récupération du manifeste des agents (`agent-manifest.csv`)
- [x] Message d'activation et introduction de l'équipe
## Progress Log
- **Agent Architecte (Timeout)** : Intervention structurelle sur l'entonnoir d'analyse :
- **Client-Side (AbortController)** : Timeout poussé de 25s à 50s pour supporter les requêtes des LLM denses (ex: M37).
- **Server-Side (N+1 Query)** : Suppression d'une requête BDD redondante invoquée par chaque produit, éliminant ~100 appels BDD par lot.
- **Agent Architecte (Gamme C)** : Prompt v5 → Mary ne recommande plus que A ou Z. Gamme C interdite (réservée saisonniers). Garde-fou C→A côté client. Concurrence réduite à 2 workers, max_tokens réduit à 150.
- **Bouton Analyse IA** : Refactorisé en `apple-btn-secondary` (sobre, conforme PRD Apple-style) avec icône Sparkles colorée en accent.
- **Vérification** : Walkthrough documenté et système fluidifié.
- BMad Master s'est présenté et a affiché le menu.
- L'utilisateur a sélectionné l'option "pm" (Party Mode).
- Chargement des directives du workflow Party Mode effectué.
-27
View File
@@ -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)
-55
View File
@@ -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);
}