mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat: Implement bulk AI product categorization through a new UI component and API route.
This commit is contained in:
1 parent
9c79b631cb
commit
ce0085a5c7
2 files changed
+161
-130
No files matched your search
@@ -11,10 +11,20 @@ const AnalyzeSchema = z.object({
|
||||
sales12m: z.record(z.string(), z.number()),
|
||||
codeGamme: z.string().nullable(),
|
||||
score: z.number().nullable().optional(),
|
||||
// Champs enrichis (optionnels, envoyés par le batch)
|
||||
weightInNomenclature2: z.number().optional(),
|
||||
adjustedCaWeight: z.number().optional(),
|
||||
nomenclature2Weight: z.number().optional(),
|
||||
scorePercentile: z.number().optional(),
|
||||
moisActifs: z.number().optional(),
|
||||
nomenclature: z.string().optional(),
|
||||
// Mode batch : retourne du JSON structuré au lieu de texte libre
|
||||
batchMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const SYSTEM_PROMPT = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE.
|
||||
|
||||
const SYSTEM_PROMPT_INDIVIDUAL = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE.
|
||||
|
||||
L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE.
|
||||
|
||||
@@ -31,39 +41,78 @@ DÉFINITION :
|
||||
Réponse courte, mentionnant la contribution économique et le score. Ne mentionne aucun seuil en euros.
|
||||
Format: "[Recommandation]: [Justification basée sur la valeur et le score]"`;
|
||||
|
||||
const SYSTEM_PROMPT_BATCH = `Tu es un expert en assortiment retail. Categorise ce produit : A (garder en permanence), C (saisonnier), Z (sortir de la gamme).
|
||||
|
||||
L'IMPORTANCE ECONOMIQUE PRIME SUR LE SCORE.
|
||||
|
||||
DONNEES CLES :
|
||||
- weightInNomenclature2 : % du CA du produit dans sa categorie. LE CRITERE PRINCIPAL. >= 5% = pilier de categorie → A.
|
||||
- adjustedCaWeight : % du CA produit dans le total fournisseur (extrapole reseau).
|
||||
- scorePercentile : rang du produit parmi le fournisseur (50 = median). Utilise CA POUR COMPARER, pas le score brut.
|
||||
- moisActifs : mois avec ventes > 0 sur 12 mois. >= 8 = rotation reguliere → A. <= 3 = sporadique.
|
||||
|
||||
REGLES :
|
||||
1. weightInNomenclature2 >= 5% → A (pilier de categorie)
|
||||
2. moisActifs >= 8 → A (fond de rayon, rotation reguliere)
|
||||
3. scorePercentile >= 50 ET moisActifs >= 4 → A (performeur regulier)
|
||||
4. moisActifs 2-4 avec pics concentres → C (saisonnier)
|
||||
5. Z seulement si TOUT est faible : weightInNomenclature2 bas + moisActifs < 5 + scorePercentile < 30
|
||||
|
||||
Reponds UNIQUEMENT en JSON : {"recommandationGamme":"A|C|Z","justificationCourte":"..."}`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log("[AI] Analysis request received.");
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
const model = config?.openRouterModel || "google/gemini-flash-1.5";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("[AI] OPENROUTER_API_KEY is missing (neither in env nor in config file).");
|
||||
console.error("[AI] OPENROUTER_API_KEY is missing.");
|
||||
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 });
|
||||
}
|
||||
|
||||
console.log(`[AI] Using model: ${model} with API key starting with ${apiKey.substring(0, 10)}...`);
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = AnalyzeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme, score } = parsed.data;
|
||||
const p = parsed.data;
|
||||
const isBatch = p.batchMode === true;
|
||||
|
||||
// Format monthly sales for the prompt
|
||||
const monthlySummary = Object.entries(sales12m)
|
||||
const monthlySummary = Object.entries(p.sales12m)
|
||||
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
|
||||
.join(", ");
|
||||
|
||||
const userMessage = `Produit: "${libelle1}" (Code: ${codein})
|
||||
Gamme actuelle: ${codeGamme ?? "Non définie"}
|
||||
Score de performance: ${score ?? "N/A"}/100
|
||||
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
|
||||
let userMessage: string;
|
||||
let systemPrompt: string;
|
||||
|
||||
if (isBatch) {
|
||||
// Mode batch : prompt simplifié avec métriques enrichies, PAS de codeGamme
|
||||
systemPrompt = SYSTEM_PROMPT_BATCH;
|
||||
const enrichedLines = [
|
||||
`Produit: "${p.libelle1}" (${p.codein})`,
|
||||
p.nomenclature ? `Nomenclature: ${p.nomenclature}` : null,
|
||||
`CA: ${p.totalCa.toFixed(0)} EUR | Marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unites`,
|
||||
p.weightInNomenclature2 != null ? `Poids dans sa categorie N2: ${p.weightInNomenclature2}%` : null,
|
||||
p.adjustedCaWeight != null ? `Poids global fournisseur (ajuste): ${p.adjustedCaWeight}%` : null,
|
||||
p.nomenclature2Weight != null ? `Poids de la categorie N2 dans le fournisseur: ${p.nomenclature2Weight}%` : null,
|
||||
p.scorePercentile != null ? `Rang percentile: ${p.scorePercentile}/100` : null,
|
||||
p.moisActifs != null ? `Mois actifs (ventes > 0): ${p.moisActifs}/12` : null,
|
||||
`Historique: ${monthlySummary}`,
|
||||
].filter(Boolean).join("\n");
|
||||
userMessage = enrichedLines;
|
||||
} else {
|
||||
// Mode individuel : prompt original
|
||||
systemPrompt = SYSTEM_PROMPT_INDIVIDUAL;
|
||||
userMessage = `Produit: "${p.libelle1}" (Code: ${p.codein})
|
||||
Gamme actuelle: ${p.codeGamme ?? "Non définie"}
|
||||
Score de performance: ${p.score ?? "N/A"}/100
|
||||
CA total 12m: ${p.totalCa.toFixed(0)}€ | Taux de marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unités
|
||||
Historique mensuel: ${monthlySummary}
|
||||
|
||||
Quelle gamme recommandes-tu et pourquoi ?`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENROUTER_URL, {
|
||||
@@ -77,23 +126,47 @@ Quelle gamme recommandes-tu et pourquoi ?`;
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
...(isBatch ? { response_format: { type: "json_object" } } : {}),
|
||||
max_tokens: 150,
|
||||
temperature: 0.3,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
||||
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
||||
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible.";
|
||||
const content: string = data.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
return NextResponse.json({ insight, codein });
|
||||
if (isBatch) {
|
||||
// Parse JSON response for batch mode
|
||||
let jsonStr = content;
|
||||
const start = content.indexOf("{");
|
||||
const end = content.lastIndexOf("}");
|
||||
if (start !== -1 && end !== -1) {
|
||||
jsonStr = content.slice(start, end + 1);
|
||||
}
|
||||
const result = JSON.parse(jsonStr);
|
||||
return NextResponse.json({
|
||||
codein: p.codein,
|
||||
recommandationGamme: result.recommandationGamme || "A",
|
||||
justificationCourte: result.justificationCourte || "",
|
||||
isDuplicate: false,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({ insight: content, codein: p.codein });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
|
||||
@@ -4,7 +4,6 @@ import React, { useState } 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 } from "lucide-react";
|
||||
import { ProductRow } from "@/types/grid";
|
||||
|
||||
export function BulkAiAnalyzer() {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -15,29 +14,16 @@ export function BulkAiAnalyzer() {
|
||||
const handleAnalyze = async () => {
|
||||
const { rows } = useGridStore.getState();
|
||||
const supplierTotalCa = rows.reduce((acc, row) => acc + (row.totalCa || 0), 0);
|
||||
const supplierTotalMarge = rows.reduce((acc, row) => acc + (row.totalMarge || 0), 0);
|
||||
|
||||
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge });
|
||||
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
|
||||
|
||||
// --- Statistiques globales pour contextualiser le score ---
|
||||
const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b);
|
||||
const totalProducts = rows.length;
|
||||
|
||||
const medianScore = totalProducts > 0
|
||||
? (totalProducts % 2 === 0
|
||||
? (allScores[totalProducts / 2 - 1] + allScores[totalProducts / 2]) / 2
|
||||
: allScores[Math.floor(totalProducts / 2)])
|
||||
: 0;
|
||||
|
||||
const scoreDistribution = {
|
||||
above70: allScores.filter(s => s > 70).length,
|
||||
between30and70: allScores.filter(s => s >= 30 && s <= 70).length,
|
||||
below30: allScores.filter(s => s < 30).length,
|
||||
};
|
||||
|
||||
const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1);
|
||||
|
||||
// Percentile par produit (rang centile 0-100)
|
||||
// Percentile par produit
|
||||
const scorePercentileMap = new Map<string, number>();
|
||||
rows.forEach(r => {
|
||||
const productScore = r.score || 0;
|
||||
@@ -45,7 +31,7 @@ export function BulkAiAnalyzer() {
|
||||
scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100));
|
||||
});
|
||||
|
||||
// --- Double poids par nomenclature niveau 2 ---
|
||||
// Double poids par nomenclature niveau 2
|
||||
const nomenclature2CaMap = new Map<string, number>();
|
||||
rows.forEach(r => {
|
||||
const n2 = r.libelleNiveau2 || "Non classe";
|
||||
@@ -59,99 +45,69 @@ export function BulkAiAnalyzer() {
|
||||
: 0);
|
||||
});
|
||||
|
||||
const nomenclature2Count = nomenclature2CaMap.size;
|
||||
// Préparer les payloads enrichis pour chaque produit
|
||||
const productPayloads = rows.map(r => {
|
||||
const rawCaWeight = supplierTotalCa > 0
|
||||
? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3))
|
||||
: 0;
|
||||
const storeCount = r.workingStores?.length || 1;
|
||||
const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1;
|
||||
const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3));
|
||||
const n2 = r.libelleNiveau2 || "Non classe";
|
||||
const n2Ca = nomenclature2CaMap.get(n2) || 1;
|
||||
const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2));
|
||||
const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length;
|
||||
|
||||
console.log("[BulkAiAnalyzer] Stats:", { totalProducts, medianScore, scoreDistribution, nomenclature2Count, maxStoreCount });
|
||||
return {
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
totalCa: r.totalCa || 0,
|
||||
tauxMarge: r.tauxMarge || 0,
|
||||
totalQuantite: r.totalQuantite || 0,
|
||||
sales12m: r.sales12m || {},
|
||||
codeGamme: r.codeGamme || null,
|
||||
score: r.score || 0,
|
||||
// Champs enrichis
|
||||
weightInNomenclature2,
|
||||
adjustedCaWeight,
|
||||
nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0,
|
||||
scorePercentile: scorePercentileMap.get(r.codein) ?? 0,
|
||||
moisActifs,
|
||||
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`,
|
||||
batchMode: true,
|
||||
};
|
||||
});
|
||||
|
||||
setIsAnalyzing(true);
|
||||
let completed = 0;
|
||||
let errors = 0;
|
||||
|
||||
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
|
||||
|
||||
try {
|
||||
// Group by Nomenclature (libelle3 or a fallback)
|
||||
const groups: Record<string, ProductRow[]> = {};
|
||||
rows.forEach(r => {
|
||||
const rayon = r.libelle3 || "Non classifié";
|
||||
if (!groups[rayon]) groups[rayon] = [];
|
||||
groups[rayon].push(r);
|
||||
});
|
||||
|
||||
const CHUNK_SIZE = 50;
|
||||
const chunks: { rayon: string; items: ProductRow[] }[] = [];
|
||||
|
||||
for (const [rayon, items] of Object.entries(groups)) {
|
||||
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
|
||||
chunks.push({ rayon, items: items.slice(i, i + CHUNK_SIZE) });
|
||||
}
|
||||
}
|
||||
|
||||
setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 });
|
||||
|
||||
// Process with a continuous concurrency limit
|
||||
const CONCURRENCY = 3;
|
||||
const remainingChunks = [...chunks];
|
||||
const CONCURRENCY = 5;
|
||||
const remaining = [...productPayloads];
|
||||
|
||||
const processNext = async () => {
|
||||
const chunk = remainingChunks.shift();
|
||||
if (!chunk) return;
|
||||
|
||||
const payloadProducts = chunk.items.map(r => {
|
||||
const rawCaWeight = supplierTotalCa > 0
|
||||
? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3))
|
||||
: 0;
|
||||
const storeCount = r.workingStores?.length || 1;
|
||||
const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1;
|
||||
const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3));
|
||||
const n2 = r.libelleNiveau2 || "Non classe";
|
||||
const n2Ca = nomenclature2CaMap.get(n2) || 1;
|
||||
const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2));
|
||||
|
||||
// Compter les mois actifs (ventes > 0) pré-calculé
|
||||
const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length;
|
||||
|
||||
return {
|
||||
codein: r.codein,
|
||||
nom: r.libelle1,
|
||||
ca: r.totalCa || 0,
|
||||
adjustedCaWeight,
|
||||
weightInNomenclature2,
|
||||
nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0,
|
||||
ventes: r.totalQuantite || 0,
|
||||
marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0,
|
||||
scorePercentile: scorePercentileMap.get(r.codein) ?? 0,
|
||||
moisActifs,
|
||||
storeCount,
|
||||
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`,
|
||||
};
|
||||
});
|
||||
const payload = remaining.shift();
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) {
|
||||
res = await fetch("/api/ai/batch-analyze", {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rayon: chunk.rayon,
|
||||
products: payloadProducts,
|
||||
supplierTotalCa,
|
||||
supplierTotalMarge,
|
||||
supplierStats: {
|
||||
totalProducts,
|
||||
medianScore: Math.round(medianScore * 10) / 10,
|
||||
scoreDistribution,
|
||||
maxStoreCount,
|
||||
nomenclature2Count,
|
||||
}
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.status === 429 && attempt < MAX_CHUNK_RETRIES) {
|
||||
if (res.status === 429 && attempt < MAX_RETRIES) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
const waitSecs = errData.retryAfter ?? (30 * attempt);
|
||||
const waitSecs = errData.retryAfter ?? (15 * attempt);
|
||||
for (let t = waitSecs; t > 0; t--) {
|
||||
setProgress(prev => ({ ...prev, message: `⏳ Lot ${completed + 1}/${chunks.length} — Limite API, reprise dans ${t}s...` }));
|
||||
setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
continue;
|
||||
@@ -160,41 +116,43 @@ export function BulkAiAnalyzer() {
|
||||
}
|
||||
|
||||
if (!res || !res.ok) {
|
||||
const errText = await res?.text().catch(() => "");
|
||||
console.error(`[BulkAiAnalyzer] Erreur HTTP sur le lot ${chunk.rayon}:`, errText);
|
||||
setProgress(prev => ({ ...prev, errors: prev.errors + 1 }));
|
||||
errors++;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (data && Array.isArray(data.results)) {
|
||||
data.results.forEach((reco: any) => {
|
||||
if (reco.codein && reco.recommandationGamme) {
|
||||
setDraftGamme(reco.codein, reco.recommandationGamme);
|
||||
const justification = `Gamme ${reco.recommandationGamme} — ${reco.justificationCourte || "Analyse effectuée."}`;
|
||||
setInsight(reco.codein, justification, reco.isDuplicate ?? false);
|
||||
}
|
||||
});
|
||||
if (data.recommandationGamme) {
|
||||
setDraftGamme(data.codein, data.recommandationGamme);
|
||||
const justification = `Gamme ${data.recommandationGamme} — ${data.justificationCourte || "Analyse effectuée."}`;
|
||||
setInsight(data.codein, justification, data.isDuplicate ?? false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e);
|
||||
setProgress(prev => ({ ...prev, errors: prev.errors + 1 }));
|
||||
} catch {
|
||||
errors++;
|
||||
} finally {
|
||||
completed++;
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
setProgress({
|
||||
current: completed,
|
||||
message: completed === chunks.length ? "Analyse terminée" : `Analyse: ${completed}/${chunks.length} lots`
|
||||
}));
|
||||
// Process next chunk in the same worker
|
||||
total: productPayloads.length,
|
||||
message: completed === productPayloads.length
|
||||
? "Analyse terminée"
|
||||
: `Analyse: ${completed}/${productPayloads.length} produits`,
|
||||
errors,
|
||||
});
|
||||
await processNext();
|
||||
}
|
||||
};
|
||||
|
||||
// Start workers
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, chunks.length) }, () => processNext());
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, productPayloads.length) },
|
||||
() => processNext()
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
setProgress(prev => ({ ...prev, current: completed, total: chunks.length, message: `Analyse terminée ! (${prev.errors > 0 ? prev.errors + " erreurs" : "succès"})` }));
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
|
||||
}));
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
|
||||
} catch (error) {
|
||||
@@ -227,7 +185,7 @@ export function BulkAiAnalyzer() {
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" />
|
||||
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||
Analyse terminée ({progress.total} lots analysés)
|
||||
Analyse terminée ({progress.total} produits analysés)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user