mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge branch 'main' of https://github.com/R0m1k3/CollectFlow
This commit is contained in:
commit
2a76745f29
13 files changed
+599
-253
No files matched your search
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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é.
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user