feat: implement AI Copilot for product analysis, featuring dedicated API, business logic, UI components, and comprehensive documentation.

This commit is contained in:
Michael committed 2026-02-25 10:58:12 +01:00
1 parent 7a6b515db7
commit 1c03403e9a
8 files changed
+229 -11

No files matched your search

@@ -38,9 +38,15 @@ CONTRIBUTION(%) :
GARDES-FOUS : ${p.scoring.isTop30Supplier ? "Oui (Top 30% Fournisseur)" : "Non"} | Récent : ${p.scoring.isRecent ? "Oui" : "Non"} | Dernier Prod: ${p.scoring.isLastProduct ? "Oui" : "Non"}
` : "";
const contextRules = p.supplierContext ? `
--- RÈGLES MÉTIER SPÉCIFIQUES ---
Le manager a défini ces règles absolues pour ce fournisseur (TU DOIS LES RESPECTER EN PRIORITÉ) :
"${p.supplierContext}"
` : "";
return `PRODUIT: ${p.libelle1} (${p.codein})
PERFORMANCE: Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€
${weights}${scoringInfo}
${weights}${scoringInfo}${contextRules}
Verdict algorithmique: ${p.scoring?.decision || "Non calculé"}
Justifie ce verdict auprès de l'utilisateur.`;
}
@@ -31,16 +31,18 @@ export interface ProductAnalysisInput {
codeFournisseur?: string;
totalMagasins?: number;
isLastProductOfSupplier?: boolean;
/** Résultats du ScoringEngine (optionnel pour l'IA) */
/** Scoring metadata */
scoring?: {
compositeScore: number;
decision: string;
decision: "A" | "Z";
labelProfil: string;
isTop30Supplier: boolean;
isRecent: boolean;
isLastProduct: boolean;
threshold: number;
};
/** Optional context rules for the supplier */
supplierContext?: string;
}
export interface AnalysisResult {
@@ -7,8 +7,13 @@ import { ScoringEngine } from "@/features/ai-copilot/business/scoring-engine";
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { ProductRow, GammeCode } from "@/types/grid";
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
import { SupplierAiContextModal } from "./supplier-ai-context-modal";
export function BulkAiAnalyzer() {
const rows = useGridStore((s) => s.rows);
const supplierCode = rows.length > 0 ? rows[0].codeFournisseur : null;
const supplierName = rows.length > 0 ? rows[0].nomFournisseur : null;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
const isCancelledRef = useRef(false);
@@ -243,12 +248,15 @@ export function BulkAiAnalyzer() {
}
return (
<button
onClick={handleAnalyze}
className="group flex items-center justify-center gap-2 h-10 px-6 rounded-xl text-sm font-black transition-all shadow-md bg-[var(--accent)] text-white hover:brightness-110 active:scale-95 border border-white/10"
>
<Sparkles className="w-4 h-4" />
Analyse IA
</button>
<div className="flex items-center gap-3">
<SupplierAiContextModal codeFournisseur={supplierCode} nomFournisseur={supplierName} />
<button
onClick={handleAnalyze}
className="group flex items-center justify-center gap-2 h-10 px-6 rounded-xl text-sm font-black transition-all shadow-md bg-[var(--accent)] text-white hover:brightness-110 active:scale-95 border border-white/10"
>
<Sparkles className="w-4 h-4" />
Analyse IA
</button>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Brain } from "lucide-react";
interface SupplierAiContextModalProps {
codeFournisseur: string | null;
nomFournisseur: string | null;
}
export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: SupplierAiContextModalProps) {
const [open, setOpen] = useState(false);
const [context, setContext] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open && codeFournisseur) {
fetchContext();
}
}, [open, codeFournisseur]);
const fetchContext = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/ai/context?fournisseur=${codeFournisseur}`);
if (res.ok) {
const data = await res.json();
setContext(data.context || "");
}
} catch (error) {
console.error("Failed to load AI context", error);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!codeFournisseur) return;
setIsSaving(true);
try {
const res = await fetch("/api/ai/context", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeFournisseur, context }),
});
if (res.ok) {
setOpen(false);
} else {
console.error("Failed to save AI context");
}
} catch (error) {
console.error("Failed to save AI context", error);
} finally {
setIsSaving(false);
}
};
if (!codeFournisseur) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Brain className="w-4 h-4" />
Règles IA
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Contexte IA - {nomFournisseur || codeFournisseur}</DialogTitle>
<DialogDescription>
Définissez ici les règles métier absolues pour ce fournisseur.
Mary (l'IA) respectera ces consignes en priorité lors de ses recommandations (ex: "Les calendriers vont en gamme C").
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
placeholder="Ex: Les produits dont le nom contient 'Agenda' doivent toujours être classés en [A]..."
value={context}
onChange={(e) => setContext(e.target.value)}
disabled={isLoading}
className="min-h-[150px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSaving}>
Annuler
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading} className="gap-2 bg-gradient-to-r from-violet-600 to-indigo-600 text-white hover:from-violet-700 hover:to-indigo-700">
{isSaving ? "Enregistrement..." : "Enregistrer les règles"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}