Files
demat-facturation/client/src/pages/Settings.tsx

1004 lines
41 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import {
Loader2,
Save,
CheckCircle,
Brain,
Key,
Server,
FileCheck,
AlertCircle,
Sparkles,
PenLine,
Plus,
Trash2,
Upload,
User
} from "lucide-react";
// useRef, useCallback already imported above
import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
function SignaturesSection() {
const utils = trpc.useUtils();
const { data: signatures, isLoading } = trpc.signatures.list.useQuery();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [mode, setMode] = useState<"file" | "draw">("draw");
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [fileData, setFileData] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const [mimeType, setMimeType] = useState("image/png");
const fileInputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const lastPosRef = useRef<{ x: number; y: number } | null>(null);
// Initialize canvas
useEffect(() => {
if (mode !== "draw") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#1e293b";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}, [mode]);
const getPos = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
if ("touches" in e) {
const touch = e.touches[0];
return { x: (touch.clientX - rect.left) * scaleX, y: (touch.clientY - rect.top) * scaleY };
}
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
}, []);
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
isDrawingRef.current = true;
lastPosRef.current = getPos(e, canvas);
}, [getPos]);
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (!isDrawingRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx || !lastPosRef.current) return;
const pos = getPos(e, canvas);
ctx.beginPath();
ctx.moveTo(lastPosRef.current.x, lastPosRef.current.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPosRef.current = pos;
}, [getPos]);
const stopDrawing = useCallback(() => {
isDrawingRef.current = false;
lastPosRef.current = null;
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
setFileData(null);
setPreviewUrl(null);
}, []);
const captureCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
setFileData(base64);
setPreviewUrl(dataUrl);
setFileName("signature-dessinee.png");
setMimeType("image/png");
}, []);
const uploadMutation = trpc.signatures.upload.useMutation({
onSuccess: () => {
toast.success("Signature ajoutée avec succès !");
utils.signatures.list.invalidate();
setFirstName("");
setLastName("");
setPreviewUrl(null);
setFileData(null);
setFileName("");
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'ajout de la signature");
},
});
const deleteMutation = trpc.signatures.delete.useMutation({
onSuccess: () => {
toast.success("Signature supprimée");
utils.signatures.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la suppression");
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Veuillez sélectionner une image (PNG, JPG, GIF...)");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 2 Mo");
return;
}
setFileName(file.name);
setMimeType(file.type);
const reader = new FileReader();
reader.onload = (ev) => {
const result = ev.target?.result as string;
// result is "data:image/png;base64,XXXX"
const base64 = result.split(",")[1];
setFileData(base64);
setPreviewUrl(result);
};
reader.readAsDataURL(file);
};
const handleAdd = () => {
if (!firstName.trim() || !lastName.trim()) {
toast.error("Veuillez renseigner le prénom et le nom");
return;
}
// In draw mode, capture canvas first
let finalFileData = fileData;
let finalFileName = fileName;
let finalMimeType = mimeType;
if (mode === "draw") {
const canvas = canvasRef.current;
if (!canvas) { toast.error("Erreur canvas"); return; }
const dataUrl = canvas.toDataURL("image/png");
finalFileData = dataUrl.split(",")[1];
finalFileName = "signature-dessinee.png";
finalMimeType = "image/png";
}
if (!finalFileData) {
toast.error(mode === "draw" ? "Veuillez dessiner votre signature" : "Veuillez sélectionner une image de signature");
return;
}
uploadMutation.mutate({ firstName: firstName.trim(), lastName: lastName.trim(), fileName: finalFileName, fileData: finalFileData, mimeType: finalMimeType });
};
return (
<div className="space-y-6">
{/* Header */}
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-950/20 dark:to-teal-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-500 rounded-lg">
<PenLine className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Gestion des signatures</CardTitle>
<CardDescription className="mt-1">
Ajoutez les signatures des responsables pour les documents officiels
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Add form */}
<div className="p-4 bg-muted/30 rounded-lg border border-dashed border-muted-foreground/30 space-y-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Ajouter une signature</p>
{/* Nom / Prénom */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sig-firstname" className="font-medium">Prénom</Label>
<Input
id="sig-firstname"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Ex : Jean"
className="h-10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sig-lastname" className="font-medium">Nom</Label>
<Input
id="sig-lastname"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Ex : Dupont"
className="h-10"
/>
</div>
</div>
{/* Mode selector */}
<div className="flex gap-2">
<Button
type="button"
variant={mode === "draw" ? "default" : "outline"}
size="sm"
className={mode === "draw" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : ""}
onClick={() => { setMode("draw"); setPreviewUrl(null); setFileData(null); setFileName(""); }}
>
<PenLine className="w-4 h-4 mr-1" /> Dessiner
</Button>
<Button
type="button"
variant={mode === "file" ? "default" : "outline"}
size="sm"
className={mode === "file" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : ""}
onClick={() => { setMode("file"); setPreviewUrl(null); setFileData(null); setFileName(""); clearCanvas(); }}
>
<Upload className="w-4 h-4 mr-1" /> Importer un fichier
</Button>
</div>
{/* Draw mode */}
{mode === "draw" && (
<div className="space-y-2">
<Label className="font-medium">Dessinez votre signature ci-dessous</Label>
<div className="relative rounded-lg border-2 border-slate-300 bg-white overflow-hidden" style={{ touchAction: "none" }}>
<canvas
ref={canvasRef}
width={600}
height={180}
className="w-full cursor-crosshair block"
style={{ touchAction: "none" }}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
<div className="absolute bottom-1 right-2 text-xs text-slate-400 pointer-events-none select-none">
Signez ici
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={clearCanvas}
className="text-muted-foreground"
>
<Trash2 className="w-3.5 h-3.5 mr-1" /> Effacer
</Button>
</div>
)}
{/* File mode */}
{mode === "file" && (
<div className="space-y-2">
<Label className="font-medium">Image de la signature</Label>
<div
className="flex items-center gap-4 p-3 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{previewUrl ? (
<img src={previewUrl} alt="Aperçu signature" className="h-16 max-w-[200px] object-contain rounded border bg-white p-1" />
) : (
<div className="flex flex-col items-center justify-center w-full py-4 text-muted-foreground gap-2">
<Upload className="w-8 h-8" />
<span className="text-sm">Cliquez pour sélectionner une image (PNG, JPG, GIF...)</span>
<span className="text-xs">Taille max : 2 Mo</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
{previewUrl && (
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => { setPreviewUrl(null); setFileData(null); setFileName(""); if (fileInputRef.current) fileInputRef.current.value = ""; }}>
Changer l'image
</Button>
)}
</div>
)}
<Button
onClick={handleAdd}
disabled={uploadMutation.isPending || !firstName || !lastName}
className="w-full h-10 bg-emerald-600 hover:bg-emerald-700 text-white gap-2"
>
{uploadMutation.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Enregistrement...</>
) : (
<><Plus className="w-4 h-4" /> Ajouter la signature</>
)}
</Button>
</div>
{/* Signatures list */}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : !signatures || signatures.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground gap-3">
<PenLine className="w-10 h-10 opacity-30" />
<p className="text-sm">Aucune signature enregistrée</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{signatures.length} signature{signatures.length > 1 ? "s" : ""} enregistrée{signatures.length > 1 ? "s" : ""}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{signatures.map((sig) => (
<div
key={sig.id}
className="group relative flex flex-col items-center gap-3 p-4 rounded-xl border-2 border-border hover:border-emerald-300 bg-card transition-all shadow-sm hover:shadow-md"
>
{/* Signature image */}
<div className="w-full h-24 flex items-center justify-center bg-white rounded-lg border overflow-hidden p-2">
<img
src={sig.imageUrl}
alt={`Signature de ${sig.firstName} ${sig.lastName}`}
className="max-h-full max-w-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).src = ""; }}
/>
</div>
{/* Name */}
<div className="flex items-center gap-2 text-center">
<div className="p-1.5 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">
<User className="w-4 h-4 text-emerald-600" />
</div>
<span className="font-semibold text-sm">{sig.firstName} {sig.lastName}</span>
</div>
{/* Date */}
<p className="text-xs text-muted-foreground">
Ajoutée le {new Date(sig.createdAt).toLocaleDateString("fr-FR")}
</p>
{/* Delete button */}
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => deleteMutation.mutate({ id: sig.id })}
disabled={deleteMutation.isPending}
title="Supprimer cette signature"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
function LlmFieldsConfigSection() {
const { data: fields, isLoading } = trpc.llmFieldsConfig.getAll.useQuery();
const utils = trpc.useUtils();
const updateFieldMutation = trpc.llmFieldsConfig.updateField.useMutation({
onSuccess: () => {
toast.success("Configuration mise à jour");
utils.llmFieldsConfig.getAll.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la mise à jour");
},
});
const handleToggle = (fieldName: string, currentValue: number) => {
updateFieldMutation.mutate({
fieldName,
isRequired: currentValue === 1 ? 0 : 1,
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
const requiredCount = fields?.filter(f => f.isRequired === 1).length || 0;
const totalCount = fields?.length || 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500 rounded-lg">
<FileCheck className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">Configuration actuelle</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
{requiredCount} champ{requiredCount > 1 ? 's' : ''} obligatoire{requiredCount > 1 ? 's' : ''} sur {totalCount}
</p>
</div>
</div>
<Badge variant="secondary" className="text-sm">
Score 100%
</Badge>
</div>
<p className="text-sm text-muted-foreground flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
Les champs marqués comme obligatoires doivent être détectés pour atteindre un score de 100%.
Les champs optionnels n'affectent pas le score.
</span>
</p>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-semibold">Champ</TableHead>
<TableHead className="text-center font-semibold">Statut</TableHead>
<TableHead className="text-center font-semibold">Obligatoire</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields?.map((field) => (
<TableRow key={field.id} className="hover:bg-muted/30 transition-colors">
<TableCell className="font-medium">{field.displayName}</TableCell>
<TableCell className="text-center">
{field.isRequired === 1 ? (
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
Obligatoire
</Badge>
) : (
<Badge variant="secondary">
Optionnel
</Badge>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Checkbox
checked={field.isRequired === 1}
onCheckedChange={() => handleToggle(field.fieldName, field.isRequired)}
disabled={updateFieldMutation.isPending}
className="data-[state=checked]:bg-green-500 data-[state=checked]:border-green-500"
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
export default function Settings() {
const { data: settings, isLoading } = trpc.settings.get.useQuery();
const utils = trpc.useUtils();
const [llmModel, setLlmModel] = useState("");
const [invoiceNumberKeywords, setInvoiceNumberKeywords] = useState("");
const [deliveryNoteKeywords, setDeliveryNoteKeywords] = useState("");
const [orderNumberKeywords, setOrderNumberKeywords] = useState("");
const [supplierKeywords, setSupplierKeywords] = useState("");
const [totalAmountKeywords, setTotalAmountKeywords] = useState("");
const [subscriptionKeywords, setSubscriptionKeywords] = useState("");
const [sftpHost, setSftpHost] = useState("");
const [sftpPort, setSftpPort] = useState(22);
const [sftpUsername, setSftpUsername] = useState("");
const [sftpPassword, setSftpPassword] = useState("");
const [sftpRemotePath, setSftpRemotePath] = useState("/");
const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
useEffect(() => {
if (settings) {
setLlmModel(settings.llmModel || "mistral-large-latest");
setInvoiceNumberKeywords(settings.invoiceNumberKeywords || "");
setDeliveryNoteKeywords(settings.deliveryNoteKeywords || "");
setOrderNumberKeywords(settings.orderNumberKeywords || "");
setSupplierKeywords(settings.supplierKeywords || "");
setTotalAmountKeywords(settings.totalAmountKeywords || "");
setSubscriptionKeywords(settings.subscriptionKeywords || "");
setSftpHost(settings.sftpHost || "");
setSftpPort(settings.sftpPort || 22);
setSftpUsername(settings.sftpUsername || "");
setSftpPassword(settings.sftpPassword || "");
setSftpRemotePath(settings.sftpRemotePath || "/");
setSftpAutoExport(settings.sftpAutoExport === 1);
setLlmLogsRetentionMonths(settings.llmLogsRetentionMonths || 3);
}
}, [settings]);
const saveMutation = trpc.settings.upsert.useMutation({
onSuccess: () => {
toast.success("Paramètres enregistrés avec succès");
utils.settings.get.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'enregistrement");
},
});
const testSftpMutation = trpc.sftp.testConnection.useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success("✓ Connexion SFTP réussie");
} else {
toast.error("✗ Échec de la connexion SFTP");
}
},
onError: (error) => {
toast.error(error.message || "Erreur lors du test de connexion");
},
});
const handleSave = () => {
saveMutation.mutate({
llmModel,
invoiceNumberKeywords,
deliveryNoteKeywords,
orderNumberKeywords,
supplierKeywords,
totalAmountKeywords,
subscriptionKeywords,
sftpHost,
sftpPort,
sftpUsername,
sftpPassword,
sftpRemotePath,
sftpAutoExport: sftpAutoExport ? 1 : 0,
llmLogsRetentionMonths,
});
};
const handleTestSftp = () => {
testSftpMutation.mutate();
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex flex-col items-center justify-center h-64 gap-4">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
<p className="text-muted-foreground">Chargement des paramètres...</p>
</div>
</DashboardLayout>
);
}
const isSftpConfigured = sftpHost && sftpUsername;
return (
<DashboardLayout>
<div className="max-w-5xl space-y-8">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl shadow-lg">
<Sparkles className="w-7 h-7 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Paramètres
</h1>
<p className="text-muted-foreground mt-1">
Configurez l'extraction et l'export de vos factures
</p>
</div>
</div>
</div>
{/* Tabs Navigation */}
<Tabs defaultValue="llm" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 h-auto p-1 bg-muted/50">
<TabsTrigger
value="llm"
className="flex items-center gap-2 py-3 data-[state=active]:bg-background data-[state=active]:shadow-sm transition-all"
>
<Brain className="w-5 h-5" />
<span className="font-medium">Intelligence AI</span>
</TabsTrigger>
<TabsTrigger
value="keywords"
className="flex items-center gap-2 py-3 data-[state=active]:bg-background data-[state=active]:shadow-sm transition-all"
>
<Key className="w-5 h-5" />
<span className="font-medium">Mots-clés</span>
</TabsTrigger>
<TabsTrigger
value="sftp"
className="flex items-center gap-2 py-3 data-[state=active]:bg-background data-[state=active]:shadow-sm transition-all"
>
<Server className="w-5 h-5" />
<span className="font-medium">Export SFTP</span>
{isSftpConfigured && (
<Badge variant="secondary" className="ml-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Configuré
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value="signatures"
className="flex items-center gap-2 py-3 data-[state=active]:bg-background data-[state=active]:shadow-sm transition-all"
>
<PenLine className="w-5 h-5" />
<span className="font-medium">Signatures</span>
</TabsTrigger>
</TabsList>
{/* LLM Tab */}
<TabsContent value="llm" className="space-y-6 animate-in fade-in-50 duration-300">
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500 rounded-lg">
<Brain className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</Label>
<Input
id="llmModel"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="mistral-large-latest"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Nom du modèle Mistral à utiliser pour l'extraction
</p>
</div>
<div className="space-y-3">
<Label htmlFor="llmLogsRetentionMonths" className="text-base font-semibold">
Rétention des logs (mois)
</Label>
<Input
id="llmLogsRetentionMonths"
type="number"
value={llmLogsRetentionMonths}
onChange={(e) => setLlmLogsRetentionMonths(parseInt(e.target.value) || 3)}
min={1}
max={12}
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Durée de conservation des logs LLM (1-12 mois)
</p>
</div>
</CardContent>
</Card>
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500 rounded-lg">
<FileCheck className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Champs de détection</CardTitle>
<CardDescription className="mt-1">
Configurez quels champs sont obligatoires pour un score de 100%
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<LlmFieldsConfigSection />
</CardContent>
</Card>
</TabsContent>
{/* Keywords Tab */}
<TabsContent value="keywords" className="space-y-6 animate-in fade-in-50 duration-300">
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-500 rounded-lg">
<Key className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Mots-clés personnalisés</CardTitle>
<CardDescription className="mt-1">
Améliorez la détection en ajoutant vos propres mots-clés (séparés par des virgules)
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="grid gap-6">
<div className="space-y-3">
<Label htmlFor="invoiceNumberKeywords" className="text-base font-semibold">
Numéro de facture
</Label>
<Textarea
id="invoiceNumberKeywords"
value={invoiceNumberKeywords}
onChange={(e) => setInvoiceNumberKeywords(e.target.value)}
placeholder="Référence, Ref facture, Invoice ref"
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-3">
<Label htmlFor="deliveryNoteKeywords" className="text-base font-semibold">
Bon de livraison
</Label>
<Textarea
id="deliveryNoteKeywords"
value={deliveryNoteKeywords}
onChange={(e) => setDeliveryNoteKeywords(e.target.value)}
placeholder="Livraison, Delivery, Expédition"
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-3">
<Label htmlFor="orderNumberKeywords" className="text-base font-semibold">
Numéro de commande
</Label>
<Textarea
id="orderNumberKeywords"
value={orderNumberKeywords}
onChange={(e) => setOrderNumberKeywords(e.target.value)}
placeholder="Cde client, Référence commande, PO Number"
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-3">
<Label htmlFor="supplierKeywords" className="text-base font-semibold">
Fournisseur
</Label>
<Textarea
id="supplierKeywords"
value={supplierKeywords}
onChange={(e) => setSupplierKeywords(e.target.value)}
placeholder="Vendeur, Société, Émetteur"
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-3">
<Label htmlFor="totalAmountKeywords" className="text-base font-semibold">
Montant total
</Label>
<Textarea
id="totalAmountKeywords"
value={totalAmountKeywords}
onChange={(e) => setTotalAmountKeywords(e.target.value)}
placeholder="Net à payer, Total à régler, Amount due"
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-3">
<Label htmlFor="subscriptionKeywords" className="text-base font-semibold">
Abonnement
</Label>
<Textarea
id="subscriptionKeywords"
value={subscriptionKeywords}
onChange={(e) => setSubscriptionKeywords(e.target.value)}
placeholder="Abonnement, Subscription, Mensuel, Annuel, Recurring"
rows={2}
className="resize-none"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Les factures contenant ces mots-clés seront automatiquement marquées comme abonnement
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* SFTP Tab */}
<TabsContent value="sftp" className="space-y-6 animate-in fade-in-50 duration-300">
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500 rounded-lg">
<Server className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Configuration SFTP</CardTitle>
<CardDescription className="mt-1">
Paramètres d'export automatique vers un serveur SFTP
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<Label htmlFor="sftpHost" className="text-base font-semibold">Hôte SFTP</Label>
<Input
id="sftpHost"
value={sftpHost}
onChange={(e) => setSftpHost(e.target.value)}
placeholder="sftp.example.com"
className="h-11"
/>
</div>
<div className="space-y-3">
<Label htmlFor="sftpPort" className="text-base font-semibold">Port</Label>
<Input
id="sftpPort"
type="number"
value={sftpPort}
onChange={(e) => setSftpPort(parseInt(e.target.value) || 22)}
className="h-11"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<Label htmlFor="sftpUsername" className="text-base font-semibold">Nom d'utilisateur</Label>
<Input
id="sftpUsername"
value={sftpUsername}
onChange={(e) => setSftpUsername(e.target.value)}
placeholder="username"
className="h-11"
/>
</div>
<div className="space-y-3">
<Label htmlFor="sftpPassword" className="text-base font-semibold">Mot de passe</Label>
<Input
id="sftpPassword"
type="password"
value={sftpPassword}
onChange={(e) => setSftpPassword(e.target.value)}
placeholder="••••••••"
className="h-11"
/>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="sftpRemotePath" className="text-base font-semibold">Chemin distant</Label>
<Input
id="sftpRemotePath"
value={sftpRemotePath}
onChange={(e) => setSftpRemotePath(e.target.value)}
placeholder="/invoices"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Les fichiers seront organisés par date: /chemin/YYYY/MM/DD/
</p>
</div>
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg border">
<div className="space-y-1">
<Label htmlFor="sftpAutoExport" className="text-base font-semibold cursor-pointer">
Export automatique
</Label>
<p className="text-sm text-muted-foreground">
Exporter automatiquement les factures après extraction
</p>
</div>
<Switch
id="sftpAutoExport"
checked={sftpAutoExport}
onCheckedChange={setSftpAutoExport}
className="data-[state=checked]:bg-green-500"
/>
</div>
<div className="pt-4 border-t">
<Button
variant="outline"
onClick={handleTestSftp}
disabled={testSftpMutation.isPending || !sftpHost}
className="w-full h-11 text-base"
size="lg"
>
{testSftpMutation.isPending ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Test en cours...
</>
) : (
<>
<CheckCircle className="w-5 h-5 mr-2" />
Tester la connexion SFTP
</>
)}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Signatures Tab */}
<TabsContent value="signatures" className="space-y-6 animate-in fade-in-50 duration-300">
<SignaturesSection />
</TabsContent>
</Tabs>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t">
<Button
onClick={handleSave}
disabled={saveMutation.isPending}
size="lg"
className="h-12 px-8 text-base bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg hover:shadow-xl transition-all"
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="w-5 h-5 mr-2" />
Enregistrer les paramètres
</>
)}
</Button>
</div>
</div>
</DashboardLayout>
);
}