Checkpoint: Bouton Relancer corrigé (automatismes uniquement, sans LLM). Système d'apprentissage complet : table invoiceLearnings, routes tRPC, déclenchement depuis InvoiceDetail, application lors des imports, page LearningSettings dans Configuration > Apprentissages IA.
This commit is contained in:
@@ -18,6 +18,7 @@ import Users from "./pages/Users";
|
||||
import ListsAdmin from "./pages/ListsAdmin";
|
||||
import AutomationRules from "./pages/AutomationRules";
|
||||
import BapHistory from "./pages/BapHistory";
|
||||
import LearningSettings from "./pages/LearningSettings";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
@@ -36,6 +37,7 @@ function Router() {
|
||||
<Route path="/lists-admin" component={ListsAdmin} />
|
||||
<Route path="/automation-rules" component={AutomationRules} />
|
||||
<Route path="/bap-history" component={BapHistory} />
|
||||
<Route path="/learning-settings" component={LearningSettings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
@@ -66,6 +66,7 @@ const menuStructure: MenuItem[] = [
|
||||
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
|
||||
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
||||
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
||||
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -54,6 +54,8 @@ export default function InvoiceDetail() {
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
|
||||
|
||||
const updateMutation = trpc.invoices.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Facture mise à jour avec succès");
|
||||
@@ -80,6 +82,42 @@ export default function InvoiceDetail() {
|
||||
|
||||
const newQualityScore = Math.round((filledFields / totalFields) * 100);
|
||||
|
||||
// Apprentissage : enregistrer les corrections manuelles sur les champs clés
|
||||
// On compare les valeurs actuelles (invoice) avec les nouvelles valeurs (formData)
|
||||
if (invoice && formData.supplierName) {
|
||||
const supplierName = formData.supplierName || invoice.supplierName || "";
|
||||
if (!supplierName) return;
|
||||
|
||||
// isSubscription : géré séparément via le toggle dans InvoicesBAP
|
||||
// typeAchat
|
||||
if (formData.typeAchat !== (invoice.typeAchat || "")) {
|
||||
upsertLearningMutation.mutate({
|
||||
supplierName,
|
||||
fieldName: "typeAchat",
|
||||
originalValue: invoice.typeAchat || undefined,
|
||||
correctedValue: formData.typeAchat,
|
||||
});
|
||||
}
|
||||
// serviceConcerne
|
||||
if (formData.serviceConcerne !== (invoice.serviceConcerne || "")) {
|
||||
upsertLearningMutation.mutate({
|
||||
supplierName,
|
||||
fieldName: "serviceConcerne",
|
||||
originalValue: invoice.serviceConcerne || undefined,
|
||||
correctedValue: formData.serviceConcerne,
|
||||
});
|
||||
}
|
||||
// ventilationComptable
|
||||
if (formData.ventilationComptable !== (invoice.ventilationComptable || "")) {
|
||||
upsertLearningMutation.mutate({
|
||||
supplierName,
|
||||
fieldName: "ventilationComptable",
|
||||
originalValue: invoice.ventilationComptable || undefined,
|
||||
correctedValue: formData.ventilationComptable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
id: invoiceId,
|
||||
data: {
|
||||
|
||||
248
client/src/pages/LearningSettings.tsx
Normal file
248
client/src/pages/LearningSettings.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { Brain, Trash2, RefreshCw, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
typeAchat: "Type d'achat",
|
||||
serviceConcerne: "Service concerné",
|
||||
ventilationComptable: "Ventilation comptable",
|
||||
isSubscription: "Abonnement",
|
||||
};
|
||||
|
||||
const FIELD_COLORS: Record<string, string> = {
|
||||
typeAchat: "bg-blue-100 text-blue-800",
|
||||
serviceConcerne: "bg-purple-100 text-purple-800",
|
||||
ventilationComptable: "bg-green-100 text-green-800",
|
||||
isSubscription: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
|
||||
export default function LearningSettings() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: learnings, isLoading } = trpc.learnings.list.useQuery();
|
||||
|
||||
const deleteMutation = trpc.learnings.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Apprentissage supprimé");
|
||||
utils.learnings.list.invalidate();
|
||||
},
|
||||
onError: () => toast.error("Erreur lors de la suppression"),
|
||||
});
|
||||
|
||||
const deleteAllMutation = trpc.learnings.deleteAll.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Tous les apprentissages ont été supprimés");
|
||||
utils.learnings.list.invalidate();
|
||||
},
|
||||
onError: () => toast.error("Erreur lors de la suppression"),
|
||||
});
|
||||
|
||||
const grouped = (learnings || []).reduce<Record<string, typeof learnings>>((acc, l) => {
|
||||
if (!l) return acc;
|
||||
const key = l.supplierKey;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key]!.push(l);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const supplierCount = Object.keys(grouped).length;
|
||||
const totalCount = learnings?.length || 0;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<Brain className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Apprentissages du système</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Corrections manuelles mémorisées et appliquées automatiquement aux prochains imports
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Tout supprimer
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer tous les apprentissages ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action supprimera les {totalCount} apprentissage(s) mémorisé(s). Le système ne pourra plus
|
||||
appliquer automatiquement ces corrections lors des prochains imports.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => deleteAllMutation.mutate()}
|
||||
>
|
||||
Supprimer tout
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-3xl font-bold text-indigo-600">{supplierCount}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Fournisseur(s) appris</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-3xl font-bold text-indigo-600">{totalCount}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Correction(s) mémorisée(s)</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Comment ça fonctionne */}
|
||||
<Card className="border-indigo-200 bg-indigo-50/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-indigo-600" />
|
||||
Comment fonctionne l'apprentissage ?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
Quand vous modifiez manuellement le <strong>type d'achat</strong>, le <strong>service concerné</strong> ou
|
||||
la <strong>ventilation comptable</strong> d'une facture dans sa page de détail, le système mémorise cette
|
||||
correction pour ce fournisseur.
|
||||
</p>
|
||||
<p>
|
||||
Lors des prochains imports, si une facture du même fournisseur est détectée, les corrections mémorisées
|
||||
sont appliquées automatiquement après l'extraction IA et les règles d'automatisme.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des apprentissages */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
||||
Chargement...
|
||||
</div>
|
||||
) : totalCount === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Brain className="w-12 h-12 text-gray-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-500">Aucun apprentissage enregistré</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||
Modifiez manuellement les champs d'une facture (type d'achat, service, ventilation) pour que le système
|
||||
commence à apprendre.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([supplierKey, entries]) => (
|
||||
<Card key={supplierKey}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base capitalize">{supplierKey}</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entries?.length} correction(s)
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{entries?.map((learning) => (
|
||||
<div
|
||||
key={learning.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Badge className={`text-xs shrink-0 ${FIELD_COLORS[learning.fieldName] || "bg-gray-100 text-gray-800"}`}>
|
||||
{FIELD_LABELS[learning.fieldName] || learning.fieldName}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
{learning.originalValue && (
|
||||
<>
|
||||
<span className="text-muted-foreground line-through truncate">
|
||||
{learning.originalValue}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{learning.correctedValue || <em className="text-muted-foreground">vide</em>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Appliqué {learning.applyCount}×
|
||||
</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer cet apprentissage ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
La correction « {FIELD_LABELS[learning.fieldName] || learning.fieldName} → {learning.correctedValue} »
|
||||
pour le fournisseur « {supplierKey} » ne sera plus appliquée automatiquement.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => deleteMutation.mutate({ id: learning.id })}
|
||||
>
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user