diff --git a/src/app/(dashboard)/docs/ai-analysis/page.tsx b/src/app/(dashboard)/docs/ai-analysis/page.tsx new file mode 100644 index 0000000..faf4675 --- /dev/null +++ b/src/app/(dashboard)/docs/ai-analysis/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Intelligence Artificielle Copilot +

+

+ Comprendre les algorithmes et règles métiers de notre assistant d'achat. +

+
+
+
+ + {/* Grid of Rules */} +
+ {/* 1. Équité Territoriale */} + +

+ Pour éviter qu'un produit présent dans un seul magasin ne soit pénalisé par son volume brut, le système + double automatiquement 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. +

+
+ + {/* 2. Run Rate */} + +

+ Si un produit a moins de 12 mois de présence, l'IA calcule un Run Rate 12 mois. +

+ + Projection = Volume_Réel * (12 / Mois_Présence) + +

+ Cela permet de confirmer un produit Permanent [A] dès ses premiers mois s'il a une forte vélocité. +

+
+ + {/* 3. Saisonnalité */} + +

+ L'IA analyse la récence des ventes. Si aucune vente n'est constatée depuis plus de + 2 mois : +

+
    +
  • + + {"< 2 mois : Lancement probable (actif)"} +
  • +
  • + + {"> 2 mois : Alerte Saison [C] ou Sortie [Z]"} +
  • +
+
+ + {/* 4. Score Global */} + +

+ Le score affiché dans la grille combine la Quantité, le CA et la Marge. + L'IA utilise ce score comme garde-fou : +

+
    +
  • • {"> 70"} : Candidat naturel au maintien.
  • +
  • • {"< 30"} : Signal fort de déréférencement.
  • +
+
+
+ + {/* Footer / Tip */} +
+ +
+

Conseil d'expert

+

+ 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.). +

+
+
+
+ ); +} + +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 ( +
+
+
+ +
+
+

{title}

+

{description}

+
+
+
+ {children} +
+
+ ); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 664a71d..53a2e75 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -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 }, ]; diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 4d95417..244eb86 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -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 diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index a103c76..a875bad 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -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, }); }; diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 59fb85d..bf1fd44 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -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 { diff --git a/src/features/ai-copilot/store/use-ai-copilot-store.ts b/src/features/ai-copilot/store/use-ai-copilot-store.ts index d68e315..f230e2a 100644 --- a/src/features/ai-copilot/store/use-ai-copilot-store.ts +++ b/src/features/ai-copilot/store/use-ai-copilot-store.ts @@ -13,6 +13,8 @@ interface AiInsight { interface AiCopilotState { insights: Record; 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; resetInsights: () => void; } @@ -43,6 +47,24 @@ export const useAiCopilotStore = create()( })); }, + 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: {} }); }, diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index d58e9d2..2f06904 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -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(); - rows.forEach((r: any) => { + const rayonStats = new Map(); + 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(); + const rayonBenchmarks = new Map(); 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 remaining = [...productPayloads]; + 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) break; - const payload = remaining.shift(); - if (!payload) return; + setLoading(payload.codein); + setDraftGamme(payload.codein, "Aucune"); - // 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 - 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++) { - if (isCancelledRef.current) break; - - res = await fetch("/api/ai/analyze", { - method: "POST", - headers: { "Content-Type": "application/json" }, - 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)); + try { + let res: Response | null = null; + 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), + }); + if (res.ok) break; + if (res.status === 429) { + await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1))); + } + } catch (e) { + console.error(`Retry ${i} failed for ${payload.codein}`, e); } - continue; } - break; - } - if (isCancelledRef.current) return; - - if (!res || !res.ok) { - errors++; - } 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 - if (data.recommandation) { - setDraftGamme(data.codein, data.recommandation); + if (!res || !res.ok) { + setError(payload.codein, "Erreur API"); + errorsCount++; + } else { + const data = await res.json(); + setInsight(payload.codein, data.insight, data.isDuplicate); + if (data.recommandation) { + setDraftGamme(payload.codein, data.recommandation as GammeCode); + } } - } - } catch { - errors++; - } finally { - if (!isCancelledRef.current) { + } catch (err) { + console.error(`Error processing ${payload.codein}`, err); + setError(payload.codein, "Erreur"); + errorsCount++; + } finally { completed++; - setProgress({ + setProgress((prev) => ({ + ...prev, current: completed, - total: productPayloads.length, - message: completed === productPayloads.length - ? "Analyse terminée" - : `Analyse: ${completed}/${productPayloads.length} produits`, - errors, - }); - await processNext(); + message: `Analyse: ${completed}/${totalItems}`, + errors: errorsCount, + })); } } - }; + }); - const workers = Array.from( - { length: Math.min(CONCURRENCY, productPayloads.length) }, - () => processNext() - ); await Promise.all(workers); + }; - 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"})`, - })); + await processBatch(); - setTimeout(() => setIsAnalyzing(false), 3000); + setProgress((prev) => ({ + ...prev, + message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`, + })); - } catch (error) { - console.error("Erreur globale lors de l'analyse:", error); - setIsAnalyzing(false); - } + setTimeout(() => setIsAnalyzing(false), 3000); }; if (isAnalyzing) { @@ -227,10 +212,7 @@ export function BulkAiAnalyzer() {
- {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`}
);