feat: Introduce AI Copilot for bulk product analysis, including a dedicated store, UI components, and business logic.

This commit is contained in:
Michael committed 2026-02-23 00:29:45 +01:00
1 parent 03c5ab6d9e
commit 8c02771e46
7 files changed
+295 -108

No files matched your search

@@ -0,0 +1,152 @@
"use client";
import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info } from "lucide-react";
import { cn } from "@/lib/utils";
export default function AiAnalysisDocPage() {
return (
<div className="max-w-5xl mx-auto py-10 px-6 space-y-12">
{/* Header */}
<header className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-[var(--accent)] flex items-center justify-center shadow-lg shadow-brand-500/20">
<Bot className="w-7 h-7 text-white" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight text-[var(--text-primary)]">
Intelligence Artificielle Copilot
</h1>
<p className="text-[var(--text-secondary)] font-medium">
Comprendre les algorithmes et règles métiers de notre assistant d'achat.
</p>
</div>
</div>
</header>
{/* Grid of Rules */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 1. Équité Territoriale */}
<DocCard
icon={ShieldCheck}
title="Normalisation Magasins (x2)"
description="Équilibre les chances entre les produits mono-magasin et multi-magasins."
accent="teal"
>
<p className="text-[13px] leading-relaxed">
Pour éviter qu'un produit présent dans un seul magasin ne soit pénalisé par son volume brut, le système
<strong> double automatiquement</strong> toutes ses statistiques (Ventes, CA, Marge).
Cela simule sa performance s'il était présent sur les deux points de vente de référence.
</p>
</DocCard>
{/* 2. Run Rate */}
<DocCard
icon={TrendingUp}
title="Potentiel Annuel (Run Rate)"
description="Évalue les lancements sur leur dynamique réelle, pas sur leur cumul."
accent="indigo"
>
<p className="text-[13px] leading-relaxed">
Si un produit a moins de 12 mois de présence, l'IA calcule un <strong>Run Rate 12 mois</strong>.
<br /><br />
<code className="bg-[var(--bg-muted)] px-2 py-0.5 rounded text-indigo-600 dark:text-indigo-400 font-mono text-[11px]">
Projection = Volume_Réel * (12 / Mois_Présence)
</code>
<br /><br />
Cela permet de confirmer un produit <strong>Permanent [A]</strong> dès ses premiers mois s'il a une forte vélocité.
</p>
</DocCard>
{/* 3. Saisonnalité */}
<DocCard
icon={Calendar}
title="Détection Saisonnalité"
description="Distingue les ruptures temporaires des fins de vie ou saisons."
accent="amber"
>
<p className="text-[13px] leading-relaxed">
L'IA analyse la <strong>récence</strong> des ventes. Si aucune vente n'est constatée depuis plus de
<strong> 2 mois</strong> :
</p>
<ul className="mt-2 space-y-1 text-[13px]">
<li className="flex items-center gap-2">
<Zap className="w-3 h-3 text-emerald-500" />
<span>{"< 2 mois : Lancement probable (actif)"}</span>
</li>
<li className="flex items-center gap-2">
<AlertTriangle className="w-3 h-3 text-amber-500" />
<span>{"> 2 mois : Alerte Saison [C] ou Sortie [Z]"}</span>
</li>
</ul>
</DocCard>
{/* 4. Score Global */}
<DocCard
icon={Scale}
title="Score de Performance"
description="La source de vérité mathématique (0 à 100)."
accent="rose"
>
<p className="text-[13px] leading-relaxed">
Le score affiché dans la grille combine la Quantité, le CA et la Marge.
L'IA utilise ce score comme garde-fou :
</p>
<ul className="mt-2 space-y-1 text-[13px]">
<li>• <strong>{"> 70"}</strong> : Candidat naturel au maintien.</li>
<li>• <strong>{"< 30"}</strong> : Signal fort de déréférencement.</li>
</ul>
</DocCard>
</div>
{/* Footer / Tip */}
<div className="p-6 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex gap-4">
<Info className="w-6 h-6 text-[var(--accent)] shrink-0" />
<div className="space-y-1">
<p className="text-sm font-bold text-[var(--accent)]">Conseil d'expert</p>
<p className="text-[13px] text-[var(--text-secondary)]">
L'IA est un outil d'aide à la décision. Elle combine ces règles pour vous proposer une recommandation
brute, mais votre expertise terrain reste essentielle pour valider les cas particuliers (ruptures fournisseurs prolongées, promotions, etc.).
</p>
</div>
</div>
</div>
);
}
function DocCard({
icon: Icon,
title,
description,
children,
accent
}: {
icon: any,
title: string,
description: string,
children: React.ReactNode,
accent: "teal" | "indigo" | "amber" | "rose"
}) {
const colors = {
teal: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shadow-emerald-500/5",
indigo: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20 shadow-indigo-500/5",
amber: "bg-amber-500/10 text-amber-600 border-amber-500/20 shadow-amber-500/5",
rose: "bg-rose-500/10 text-rose-600 border-rose-500/20 shadow-rose-500/5",
};
return (
<div className="group p-6 rounded-2xl bg-white dark:bg-slate-900 border border-[var(--border)] shadow-sm hover:shadow-md transition-all">
<div className="flex items-start gap-4 mb-4">
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center shadow-sm border", colors[accent])}>
<Icon className="w-5 h-5" />
</div>
<div>
<h3 className="text-[15px] font-bold text-[var(--text-primary)]">{title}</h3>
<p className="text-[12px] text-[var(--text-muted)] font-medium leading-tight">{description}</p>
</div>
</div>
<div className="text-[var(--text-secondary)] leading-relaxed">
{children}
</div>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon, Loader2 } from "lucide-react";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon, Loader2, Bot } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@@ -13,6 +13,7 @@ const NAV_ITEMS = [
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports" },
{ icon: BarChart3, label: "Score", href: "/score" },
{ icon: Bot, label: "Aide IA", href: "/docs/ai-analysis" },
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
];
@@ -12,9 +12,11 @@ En analysant les données de ventes fournies, génère une recommandation de gam
Critères de pondération et Règles Métier Strictes :
1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies sont PONDÉRÉES par 2 si le produit n'est au catalogue que de 1 magasin.
2. Potentiel Annuel (Run Rate) : Si un produit est récent (Régularité < 12 mois), base ton jugement sur sa "Projection 12m" plutôt que sur son volume brut cumulé.
3. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2).
4. Score de Performance Global (0-100) : Un score > 70 est un indicateur fort pour "Permanent" (A). Un score < 30 est un indicateur fort pour "Sortie" (Z).
5. Équilibre : Un produit "A" doit justifier sa place par son flux, sa rentabilité brute OU son score global.
3. Détection Saisonnalité/Fin de vie : Si un produit est inactif depuis plus de 2 mois (Inactivité > 2), la projection 12 mois devient INCERTAINE.
- Si Inactivité > 2 et Régularité < 6 : Probable SAISONNIER [C] ou SORTIE [Z].
- Si Inactivité <= 2 et Régularité < 12 : Probable LANCEMENT (Permanent [A]).
4. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2).
5. Score de Performance Global (0-100) : Un score > 70 est un indicateur fort pour "Permanent" (A). Un score < 30 est un indicateur fort pour "Sortie" (Z).
Ta réponse doit être courte, directe et sans complaisance.
Format impératif : "[Recommandation] : [Texte brut de l'explication sans aucun préfixe du type 'Justification:' ou 'Pourquoi:']"
@@ -22,7 +24,7 @@ Exemple : "A : Volume de vente et score élevés justifiant le maintien en rayon
}
static generateUserMessage(p: ProductAnalysisInput): string {
const monthlySummary = Object.entries(p.sales12m)
const monthlySummary = Object.entries(p.sales12m || {})
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
.join(", ");
@@ -33,6 +35,10 @@ Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u ($
--- ANALYSE DE POTENTIEL (Produit récent : ${p.regularityScore}/12 mois active) ---
Projection 12 mois (Run Rate) : ${Math.round(p.projectedTotalQuantite || 0)}u (${(p.projectedTotalCa || 0).toFixed(2)}€)` : "";
const activityAlert = (p.inactivityMonths || 0) > 2
? `\n⚠️ ALERTE : Inactif depuis ${p.inactivityMonths} mois (Dernière vente: ${p.lastMonthWithSale || "Inconnue"})`
: "";
const benchmarks = `
Benchmarks Fournisseur (Global) :
- Moyenne 1 mag: ${Math.round(p.avgQtyGroup1 || 0)}u | Multi-mag: ${Math.round(p.avgQtyGroup2 || 0)}u
@@ -42,7 +48,7 @@ Benchmarks Rayon ("${p.libelleNiveau2}") :
return `Produit : "${p.libelle1}" (Ref: ${p.codein})
Rayon : ${p.libelleNiveau2 || "Non classé"}
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
${volumeInfo}${projectionInfo}${benchmarks}
${volumeInfo}${projectionInfo}${activityAlert}${benchmarks}
Indicateurs de Performance :
- Score Global App : ${p.score.toFixed(1)}/100
- Régularité des ventes : ${p.regularityScore}/12 mois
@@ -26,6 +26,25 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
const regScore = Object.values(row.sales12m || {}).filter(v => v > 0).length;
const weight = (row.workingStores?.length || 1) === 1 ? 2 : 1;
// Calcul de l'inactivité (Récence)
const allMonths = Object.keys(row.sales12m || {});
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
const salesMonths = Object.entries(row.sales12m || {})
.filter(([_, qty]) => qty > 0)
.map(([m]) => parseInt(m))
.sort((a, b) => b - a);
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
let inactivity = 0;
if (referenceMonth && lastMonth) {
const refY = parseInt(referenceMonth.substring(0, 4));
const refM = parseInt(referenceMonth.substring(4, 6));
const lastY = parseInt(lastMonth.substring(0, 4));
const lastM = parseInt(lastMonth.substring(4, 6));
inactivity = (refY - lastY) * 12 + (refM - lastM);
}
analyzeProduct({
codein: row.codein,
libelle1: row.libelle1,
@@ -38,6 +57,8 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
regularityScore: regScore,
projectedTotalQuantite: regScore > 0 ? (row.totalQuantite * weight * (12 / regScore)) : (row.totalQuantite * weight),
projectedTotalCa: regScore > 0 ? (row.totalCa * weight * (12 / regScore)) : (row.totalCa * weight),
lastMonthWithSale: lastMonth,
inactivityMonths: inactivity,
});
};
@@ -20,6 +20,9 @@ export interface ProductAnalysisInput {
/** Projection sur 12 mois si le produit est récent (Run Rate) */
projectedTotalQuantite?: number;
projectedTotalCa?: number;
/** Analyse de saisonnalité */
lastMonthWithSale?: string;
inactivityMonths?: number;
}
export interface AnalysisResult {
@@ -13,6 +13,8 @@ interface AiInsight {
interface AiCopilotState {
insights: Record<string, AiInsight>;
setInsight: (codein: string, insight: string, isDuplicate?: boolean) => void;
setLoading: (codein: string) => void;
setError: (codein: string, error: string) => void;
analyzeProduct: (payload: {
codein: string;
libelle1: string;
@@ -25,6 +27,8 @@ interface AiCopilotState {
regularityScore?: number;
projectedTotalQuantite?: number;
projectedTotalCa?: number;
lastMonthWithSale?: string;
inactivityMonths?: number;
}) => Promise<void>;
resetInsights: () => void;
}
@@ -43,6 +47,24 @@ export const useAiCopilotStore = create<AiCopilotState>()(
}));
},
setLoading: (codein) => {
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: "", status: "loading" },
},
}));
},
setError: (codein, error) => {
set((s) => ({
insights: {
...s.insights,
[codein]: { codein, insight: error, status: "error" },
},
}));
},
resetInsights: () => {
set({ insights: {} });
},
+84 -102
View File
@@ -4,38 +4,37 @@ import React, { useState, useRef } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { ProductAnalysisInput, AnalysisResult } from "@/features/ai-copilot/models/ai-analysis.types";
import { ProductRow, GammeCode } from "@/types/grid";
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
export function BulkAiAnalyzer() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
const isCancelledRef = useRef(false);
const setDraftGamme = useGridStore(state => state.setDraftGamme);
const setInsight = useAiCopilotStore(state => state.setInsight);
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
const { setInsight, setLoading, setError } = useAiCopilotStore();
const handleStop = () => {
isCancelledRef.current = true;
setProgress(prev => ({ ...prev, message: "Arrêt en cours..." }));
setProgress((prev) => ({ ...prev, message: "Arrêt en cours..." }));
};
const handleAnalyze = async () => {
const { rows } = useGridStore.getState();
isCancelledRef.current = false;
const totalQtySum = rows.reduce((sum: number, r: any) => sum + (r.totalQuantite || 0), 0);
const totalQtySum = rows.reduce((sum: number, r: ProductRow) => sum + (r.totalQuantite || 0), 0);
const avgQty = rows.length > 0 ? totalQtySum / rows.length : 0;
// Benchmarks par groupe de magasins
const rowsGroup1 = rows.filter(r => (r.workingStores?.length || 1) === 1);
const rowsGroup2 = rows.filter(r => (r.workingStores?.length || 1) >= 2);
const rowsGroup1 = rows.filter((r) => (r.workingStores?.length || 1) === 1);
const rowsGroup2 = rows.filter((r) => (r.workingStores?.length || 1) >= 2);
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0;
const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0;
const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
// Benchmarks par Rayon (Nomenclature Niveau 2)
const rayonStats = new Map<string, { qty1: number[], qty2: number[] }>();
rows.forEach((r: any) => {
const rayonStats = new Map<string, { qty1: number[]; qty2: number[] }>();
rows.forEach((r: ProductRow) => {
const rayon = r.libelleNiveau2 || "Général";
if (!rayonStats.has(rayon)) rayonStats.set(rayon, { qty1: [], qty2: [] });
const stats = rayonStats.get(rayon)!;
@@ -44,23 +43,41 @@ export function BulkAiAnalyzer() {
else stats.qty2.push(r.totalQuantite || 0);
});
const rayonBenchmarks = new Map<string, { avg1: number, avg2: number }>();
const rayonBenchmarks = new Map<string, { avg1: number; avg2: number }>();
rayonStats.forEach((stats, rayon) => {
const totalCount = stats.qty1.length + stats.qty2.length;
// Seuil de 3 produits pour justifier un benchmark spécifique par rayon
if (totalCount >= 3) {
rayonBenchmarks.set(rayon, {
avg1: stats.qty1.length > 0 ? stats.qty1.reduce((a, b) => a + b, 0) / stats.qty1.length : 0,
avg2: stats.qty2.length > 0 ? stats.qty2.reduce((a, b) => a + b, 0) / stats.qty2.length : 0
avg2: stats.qty2.length > 0 ? stats.qty2.reduce((a, b) => a + b, 0) / stats.qty2.length : 0,
});
}
});
const productPayloads: ProductAnalysisInput[] = rows.map(r => {
const productPayloads: ProductAnalysisInput[] = rows.map((r) => {
const sc = r.workingStores?.length || 1;
const weight = sc === 1 ? 2 : 1; // On ramène tout sur une base 2 magasins
const weight = sc === 1 ? 2 : 1;
const rb = rayonBenchmarks.get(r.libelleNiveau2 || "Général");
const allMonths = Object.keys(r.sales12m || {});
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map((m) => parseInt(m))).toString() : "";
const salesMonths = Object.entries(r.sales12m || {})
.filter(([_, qty]) => qty > 0)
.map(([m]) => parseInt(m))
.sort((a, b) => b - a);
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
let inactivity = 0;
if (referenceMonth && lastMonth) {
const refY = parseInt(referenceMonth.substring(0, 4));
const refM = parseInt(referenceMonth.substring(4, 6));
const lastY = parseInt(lastMonth.substring(0, 4));
const lastM = parseInt(lastMonth.substring(4, 6));
inactivity = (refY - lastY) * 12 + (refM - lastM);
}
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
return {
codein: r.codein,
libelle1: r.libelle1 || "",
@@ -76,124 +93,92 @@ export function BulkAiAnalyzer() {
avgQtyRayon1: rb ? rb.avg1 : avgQty1,
avgQtyRayon2: rb ? rb.avg2 : avgQty2,
storeCount: sc,
sales12m: Object.fromEntries(
Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])
),
sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])),
codeGamme: r.codeGamme || null,
score: r.score || 0,
regularityScore: Object.values(r.sales12m || {}).filter(v => v > 0).length,
projectedTotalQuantite: (Object.values(r.sales12m || {}).filter(v => v > 0).length > 0)
? ((r.totalQuantite || 0) * weight * (12 / Object.values(r.sales12m || {}).filter(v => v > 0).length))
: ((r.totalQuantite || 0) * weight),
projectedTotalCa: (Object.values(r.sales12m || {}).filter(v => v > 0).length > 0)
? ((r.totalCa || 0) * weight * (12 / Object.values(r.sales12m || {}).filter(v => v > 0).length))
: ((r.totalCa || 0) * weight),
regularityScore: regScore,
projectedTotalQuantite: regScore > 0 ? (r.totalQuantite || 0) * weight * (12 / regScore) : (r.totalQuantite || 0) * weight,
projectedTotalCa: regScore > 0 ? (r.totalCa || 0) * weight * (12 / regScore) : (r.totalCa || 0) * weight,
lastMonthWithSale: lastMonth,
inactivityMonths: inactivity,
};
});
setIsAnalyzing(true);
let completed = 0;
let errors = 0;
let errorsCount = 0;
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
const totalItems = productPayloads.length;
setProgress({ current: 0, total: totalItems, message: "Initialisation...", errors: 0 });
try {
const CONCURRENCY = 3; // Réduit pour éviter les 429 trop fréquents
const CONCURRENCY = 3;
const remaining = [...productPayloads];
const processNext = async () => {
if (isCancelledRef.current) return;
const processBatch = async () => {
const workers = Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, async () => {
while (remaining.length > 0 && !isCancelledRef.current) {
const payload = remaining.shift();
if (!payload) return;
if (!payload) break;
// Marquer temporairement comme loading dans le store pour le feedback visuel individuel
setInsight(payload.codein, "", false);
// Vider aussi la recommandation (A, C, Z) pour montrer que c'est en cours de recalcul
setLoading(payload.codein);
setDraftGamme(payload.codein, "Aucune");
// Note: setInsight dans le store actuel met le status à "done".
// Pour bien faire, il faudrait une action setStatusLoading dans le store.
// Ici, on va au moins vider l'insight pour montrer que ça bouge.
try {
let res: Response | null = null;
const MAX_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
for (let i = 0; i < 3; i++) {
if (isCancelledRef.current) break;
try {
res = await fetch("/api/ai/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
body: JSON.stringify(payload),
});
if (res.status === 429 && attempt < MAX_RETRIES) {
const errData = await res.json().catch(() => ({}));
const waitSecs = errData.retryAfter ?? (15 * attempt);
for (let t = waitSecs; t > 0; t--) {
if (isCancelledRef.current) break;
setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
await new Promise(r => setTimeout(r, 1000));
if (res.ok) break;
if (res.status === 429) {
await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1)));
}
continue;
} catch (e) {
console.error(`Retry ${i} failed for ${payload.codein}`, e);
}
break;
}
if (isCancelledRef.current) return;
if (!res || !res.ok) {
errors++;
setError(payload.codein, "Erreur API");
errorsCount++;
} else {
const data: AnalysisResult = await res.json();
// On met à jour l'insight dans tous les cas pour qu'il s'affiche
setInsight(data.codein, data.insight, false);
// Si une recommandation est trouvée, on l'applique à la gamme
const data = await res.json();
setInsight(payload.codein, data.insight, data.isDuplicate);
if (data.recommandation) {
setDraftGamme(data.codein, data.recommandation);
setDraftGamme(payload.codein, data.recommandation as GammeCode);
}
}
} catch {
errors++;
} catch (err) {
console.error(`Error processing ${payload.codein}`, err);
setError(payload.codein, "Erreur");
errorsCount++;
} finally {
if (!isCancelledRef.current) {
completed++;
setProgress({
current: completed,
total: productPayloads.length,
message: completed === productPayloads.length
? "Analyse terminée"
: `Analyse: ${completed}/${productPayloads.length} produits`,
errors,
});
await processNext();
}
}
};
const workers = Array.from(
{ length: Math.min(CONCURRENCY, productPayloads.length) },
() => processNext()
);
await Promise.all(workers);
setProgress(prev => ({
setProgress((prev) => ({
...prev,
current: completed,
total: productPayloads.length,
message: isCancelledRef.current
? `Analyse interrompue (${completed} traités)`
: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
message: `Analyse: ${completed}/${totalItems}`,
errors: errorsCount,
}));
}
}
});
await Promise.all(workers);
};
await processBatch();
setProgress((prev) => ({
...prev,
message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`,
}));
setTimeout(() => setIsAnalyzing(false), 3000);
} catch (error) {
console.error("Erreur globale lors de l'analyse:", error);
setIsAnalyzing(false);
}
};
if (isAnalyzing) {
@@ -227,10 +212,7 @@ export function BulkAiAnalyzer() {
<div className="flex items-center gap-2 h-10 px-4 rounded-xl bg-[var(--accent-success-bg)] border border-[var(--accent-success)] shadow-sm cursor-default">
<CheckCircle2 className="w-4 h-4 text-[var(--accent-success)]" />
<span className="text-xs font-bold text-[var(--accent-success)]">
{progress.errors > 0
? `Terminé (${progress.current - progress.errors}/${progress.total})`
: `Analyse Terminée`
}
{progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`}
</span>
</div>
);