diff --git a/package-lock.json b/package-lock.json index 88ae9de..61b9e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "collect-flow", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", diff --git a/package.json b/package.json index 53a2440..7d77914 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "docker:build": "docker-compose up -d --build" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.1.16", "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bbc7d47 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index 0fe047f..286804c 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -28,6 +28,26 @@ const MIN_RAYON_SIZE = 6; export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH"; +export interface ContextProfilerCache { + allCaPerStoreSorted: number[]; + allQtyPerStoreSorted: number[]; + allMargeValuesSorted: number[]; + + totalCaFournisseur: number; + totalQtyFournisseur: number; + + top20CaThreshold: number; + top20QtyThreshold: number; + + medianQtyPerStore: number; + medianMarge: number; + + marge40: number; + qty60PerStore: number; + + rayonStats: Map; +} + export interface ProductContextProfile { // Identité codein: string; @@ -108,28 +128,41 @@ export interface ProductContextProfile { // Helpers statistiques (purs) // --------------------------------------------------------------------------- -function computePercentile(value: number, distribution: number[]): number { - if (distribution.length <= 1) return 100; - const sorted = [...distribution].sort((a, b) => a - b); - const first = sorted.indexOf(value); - const last = sorted.lastIndexOf(value); - if (first === -1) return 0; - const avgRank = (first + last) / 2; - return Math.round((avgRank / (sorted.length - 1)) * 100); +function computePercentileFromSorted(value: number, sortedDistribution: number[]): number { + if (sortedDistribution.length <= 1) return 100; + + let first = -1; + let last = -1; + + for (let i = 0; i < sortedDistribution.length; i++) { + const current = sortedDistribution[i]; + if (Math.abs(current - value) < 0.00001) { // Floating point protection + if (first === -1) first = i; + last = i; + } else if (current > value) { + if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100); + break; + } + } + + if (first !== -1) { + const avgRank = (first + last) / 2; + return Math.round((avgRank / (sortedDistribution.length - 1)) * 100); + } + + return 100; } -function computeMedian(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); +function computeMedianFromSorted(sorted: number[]): number { + if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } -function valueAtPercentile(values: number[], p: number): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); +function valueAtPercentileFromSorted(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1); return sorted[idx]; } @@ -151,69 +184,107 @@ function getRayonKey(p: ProductAnalysisInput): string { return p.libelleNiveau2 ?? "default"; } +const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1); +const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p); +const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p); +const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0; +const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0; + // --------------------------------------------------------------------------- // Profiler principal // --------------------------------------------------------------------------- export class ContextProfiler { + + /** + * Prépare un cache contextuel basé sur tous les produits pour éviter des tris + * répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)). + */ + static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache { + const allCaPerStore = allProds.map(normCa); + const allQtyPerStore = allProds.map(normQty); + const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); + + // On trie une seule et unique fois + const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b); + const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b); + const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b); + + const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0); + const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0); + + const rayonStats = new Map(); + allProds.forEach(p => { + const key = getRayonKey(p); + if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 }); + const s = rayonStats.get(key)!; + s.totalCa += getWeightedCa(p); + s.totalQty += getWeightedQty(p); + s.count += 1; + }); + + return { + allCaPerStoreSorted, + allQtyPerStoreSorted, + allMargeValuesSorted, + totalCaFournisseur, + totalQtyFournisseur, + top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80), + top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80), + medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted), + medianMarge: computeMedianFromSorted(allMargeValuesSorted), + marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40), + qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60), + rayonStats, + }; + } + /** * Génère le profil contextuel d'un produit au sein de son lot fournisseur. */ static buildProfile( target: ProductAnalysisInput, allProds: ProductAnalysisInput[], - scoring: ScoringResult + scoring: ScoringResult, + cache?: ContextProfilerCache ): ProductContextProfile { if (allProds.length === 0) { throw new Error("[ContextProfiler] Le lot de produits est vide."); } + // Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer, + // sinon le générer à la volée (mode single item) + const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds); + // --------------------------------------------------------------------------- // Normalisation par magasin — cœur de la v3 - // Raison : un produit en 2 magasins a mécaniquement 2x plus de CA/QTÉ - // qu'un produit identique en 1 magasin. Sans normalisation, les percentiles - // et le quadrant sont fausss par le réseau de distribution, pas la perf. // --------------------------------------------------------------------------- - const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1); - const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p); - const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p); - const targetStoreCount = getNormStoreCount(target); const targetCaPerStore = normCa(target); const targetQtyPerStore = normQty(target); - // 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau) - // weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2). - const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0; - const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0; - - const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0); - const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0); + // 1. Totaux fournisseur + const { totalCaFournisseur, totalQtyFournisseur } = ctx; // 2. Totaux du rayon (Niveau 2 de nomenclature) const targetRayonKey = getRayonKey(target); - const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey); - const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0); - const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0); + const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 }; + const totalCaRayon = rayonStat.totalCa; + const totalQtyRayon = rayonStat.totalQty; + const rayonSizeForSignals = rayonStat.count; - // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant - const allCaPerStore = allProds.map(normCa); - const allQtyPerStore = allProds.map(normQty); - const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); - - const pCa = computePercentile(targetCaPerStore, allCaPerStore); - const pQty = computePercentile(targetQtyPerStore, allQtyPerStore); - const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues); + // 3. Percentiles (0-100) — sur distribution PAR MAGASIN + const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted); + const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted); + const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted); const pComposite = scoring.compositeScore; - // 4. Tops 20% (sur valeurs PAR MAGASIN) - const top20CaThreshold = valueAtPercentile(allCaPerStore, 80); - const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80); - const isTop20Ca = targetCaPerStore >= top20CaThreshold; - const isTop20Qty = targetQtyPerStore >= top20QtyThreshold; + // 4. Tops 20% + const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold; + const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold; const isAboveMedianComposite = pComposite >= 50; - // 5. Poids pondérés (valeurs réseau projetées pour représentativité réaliste) + // 5. Poids pondérés const weightCaFournisseur = totalCaFournisseur > 0 ? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10 @@ -223,52 +294,32 @@ export class ContextProfiler { ? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10 : 0; - // Bug fix : un produit dans le top 30% de marge du lot apporte une valeur - // réelle en rentabilité. L'exclure du signal isLowContribution évite de le - // pénaliser uniquement parce que son volume/CA est faible (profil 💎 pur). - // Le seuil de faible contribution utilise désormais des percentiles plutôt - // que des valeurs absolues pour ne pas pénaliser les gros fournisseurs. const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70; - // 6. Signaux Trafic / Marge + Quadrant (sur valeurs PAR MAGASIN) - // Médianes calculées ici pour être disponibles dans les sections 6 ET 7. - const medianQtyPerStore = computeMedian(allQtyPerStore); - const medianMarge = computeMedian(allMargeValues); - - const rayonSizeForSignals = rayonProds.length; + // 6. Signaux Trafic / Marge + Quadrant const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; let isHighVolumeWithLowMargin = false; let isMargePure = false; - // Bug fix : isMargePure est désormais calculé sur tous les rayons, même petits. - // Raison : la marge absolue est une donnée fiable quelle que soit la taille du lot. - // Le signal isHighVolumeWithLowMargin (trafic) garde le seuil MIN_RAYON_SIZE car - // il nécessite une distribution volumique suffisante pour être statistiquement valide. { - const marge40 = valueAtPercentile(allMargeValues, 40); - const qty60PerStore = valueAtPercentile(allQtyPerStore, 60); - - // Signal trafic : nécessite un rayon suffisamment large if (signalsActive) { isHighVolumeWithLowMargin = - targetQtyPerStore >= qty60PerStore && - (target.tauxMarge ?? 0) < marge40; + targetQtyPerStore >= ctx.qty60PerStore && + (target.tauxMarge ?? 0) < ctx.marge40; } - // Signal marge pure : au-dessus de la médiane marge + volume faible. - // Utilise la médiane (P50) plutôt que P70 pour être robuste sur petits lots. isMargePure = - (target.tauxMarge ?? 0) > medianMarge && - targetQtyPerStore < medianQtyPerStore; + (target.tauxMarge ?? 0) > ctx.medianMarge && + targetQtyPerStore < ctx.medianQtyPerStore; } - // 7. Quadrant (basé sur les médianes PAR MAGASIN — déjà calculées en section 6) + // 7. Quadrant const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( targetQtyPerStore, target.tauxMarge ?? 0, - medianQtyPerStore, - medianMarge + ctx.medianQtyPerStore, + ctx.medianMarge ); // 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index edc0b34..9f5888e 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -149,7 +149,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise= 3) { + if (periods.size >= 1) { workingStores.push(magasin); } } diff --git a/src/features/grid/components/bulk-action-toolbar.tsx b/src/features/grid/components/bulk-action-toolbar.tsx index 7cfd43c..6929e65 100644 --- a/src/features/grid/components/bulk-action-toolbar.tsx +++ b/src/features/grid/components/bulk-action-toolbar.tsx @@ -17,12 +17,16 @@ interface BulkActionToolbarProps { } export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) { - const { setDraftGamme } = useGridStore(); + const { batchSetDraftGamme } = useGridStore(); if (selectedCodeins.length === 0) return null; const applyBulk = (code: GammeCode) => { - selectedCodeins.forEach((codein) => setDraftGamme(codein, code)); + const changes = selectedCodeins.reduce((acc, codein) => { + acc[codein] = code; + return acc; + }, {} as Record); + batchSetDraftGamme(changes); onClearSelection(); }; diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 401f7d7..69a55a2 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -150,6 +150,9 @@ export function BulkAiAnalyzer() { const scoringResults = new Map>(); const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; + // Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes + const contextCache = ContextProfiler.prepareCache(initialPayloads); + for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) { if (isCancelledRef.current) break; @@ -161,7 +164,7 @@ export function BulkAiAnalyzer() { let contextProfile: ProductAnalysisInput["contextProfile"]; try { - contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes); + contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes, contextCache); } catch (err) { console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err); contextProfile = undefined; diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 1a42ad3..c37c152 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -91,6 +91,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins

+
{hasDrafts && (