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

1430 lines
63 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,
Eye,
EyeOff,
Zap,
Bot
} 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 via callback ref — fires as soon as the element mounts
const initCanvas = useCallback((canvas: HTMLCanvasElement | null) => {
(canvasRef as React.MutableRefObject<HTMLCanvasElement | null>).current = canvas;
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";
}, []);
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={initCanvas}
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>
{/* Service → Signature associations */}
<ServiceSignaturesSection signatures={signatures || []} />
</div>
);
}
function ServiceSignaturesSection({ signatures }: { signatures: Array<{ id: number; firstName: string; lastName: string; imageUrl: string }> }) {
const utils = trpc.useUtils();
const { data: departments, isLoading: depsLoading } = trpc.departments.getByUser.useQuery();
const { data: associations, isLoading: assocLoading } = trpc.serviceSignatures.list.useQuery();
const upsertMutation = trpc.serviceSignatures.upsert.useMutation({
onSuccess: () => {
toast.success("Association enregistrée");
utils.serviceSignatures.list.invalidate();
},
onError: (e) => toast.error(e.message || "Erreur lors de l'enregistrement"),
});
const deleteMutation = trpc.serviceSignatures.delete.useMutation({
onSuccess: () => {
toast.success("Association supprimée");
utils.serviceSignatures.list.invalidate();
},
onError: (e) => toast.error(e.message || "Erreur lors de la suppression"),
});
const getAssociation = (serviceName: string) =>
associations?.find(a => a.serviceName.toLowerCase() === serviceName.toLowerCase());
if (depsLoading || assocLoading) {
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle></CardHeader>
<CardContent><div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div></CardContent>
</Card>
);
}
if (!departments || departments.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">Aucun service configuré. Ajoutez des services dans l'onglet <strong>Listes</strong> d'abord.</p>
</CardContent>
</Card>
);
}
if (!signatures || signatures.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">Aucune signature enregistrée. Ajoutez des signatures ci-dessus d'abord.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><User className="w-5 h-5 text-emerald-500" />Association Service → Signature</CardTitle>
<CardDescription>Associez une signature à chaque service pour l'apposer automatiquement lors de l'export PDF BAP</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Signature associée</TableHead>
<TableHead>Aperçu</TableHead>
<TableHead className="w-20">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{departments.map((dept: { id: number; name: string; userId: number; createdAt: Date }) => {
const assoc = getAssociation(dept.name);
const currentSigId = assoc?.signatureId ?? 0;
return (
<TableRow key={dept.id}>
<TableCell className="font-medium">{dept.name}</TableCell>
<TableCell>
<select
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
value={currentSigId}
onChange={(e) => {
const sigId = parseInt(e.target.value);
if (sigId === 0) {
deleteMutation.mutate({ serviceName: dept.name });
} else {
upsertMutation.mutate({ serviceName: dept.name, signatureId: sigId });
}
}}
>
<option value={0}>— Aucune signature —</option>
{signatures.map(sig => (
<option key={sig.id} value={sig.id}>
{sig.firstName} {sig.lastName}
</option>
))}
</select>
</TableCell>
<TableCell>
{assoc && signatures.find(s => s.id === assoc.signatureId) ? (
<div className="h-10 w-24 border rounded overflow-hidden bg-white flex items-center justify-center">
<img
src={signatures.find(s => s.id === assoc.signatureId)!.imageUrl}
alt="aperçu"
className="max-h-full max-w-full object-contain"
/>
</div>
) : (
<span className="text-xs text-muted-foreground italic">Aucune</span>
)}
</TableCell>
<TableCell>
{assoc && (
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-7 w-7 p-0"
onClick={() => deleteMutation.mutate({ serviceName: dept.name })}
disabled={deleteMutation.isPending}
title="Supprimer l'association"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
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 [recipientKeywords, setRecipientKeywords] = 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 [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
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 || "");
setRecipientKeywords((settings as any).recipientKeywords || "");
setSftpHost(settings.sftpHost || "");
setSftpPort(settings.sftpPort || 22);
setSftpUsername(settings.sftpUsername || "");
setSftpPassword(settings.sftpPassword || "");
setSftpRemotePath(settings.sftpRemotePath || "/");
setSftpAutoExport(settings.sftpAutoExport === 1);
setSftpRecipientFilter((settings as any).sftpRecipientFilter || "");
setLlmLogsRetentionMonths(settings.llmLogsRetentionMonths || 3);
setAiProvider((settings as any).aiProvider || "mistral");
setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
}
}, [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,
recipientKeywords,
sftpHost,
sftpPort,
sftpUsername,
sftpPassword,
sftpRemotePath,
sftpAutoExport: sftpAutoExport ? 1 : 0,
sftpRecipientFilter,
llmLogsRetentionMonths,
aiProvider,
mistralApiKey,
manusForgeApiKey,
manusForgeApiUrl,
geminiApiKey,
});
};
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">
{/* AI Engine Selector */}
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-500 rounded-lg">
<Zap className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Moteur IA</CardTitle>
<CardDescription className="mt-1">
Choisissez le fournisseur d'intelligence artificielle pour l'extraction des factures
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Provider selector */}
<div className="grid grid-cols-3 gap-4">
<button
type="button"
onClick={() => setAiProvider("mistral")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "mistral"
? "border-orange-500 bg-orange-50 dark:bg-orange-950/20 shadow-md"
: "border-muted hover:border-orange-300 bg-background"
}`}
>
{aiProvider === "mistral" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-orange-500" />
</div>
)}
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-xl">
<Bot className="w-8 h-8 text-orange-600" />
</div>
<div className="text-center">
<p className="font-bold text-base">Mistral AI</p>
<p className="text-xs text-muted-foreground mt-1">API Mistral directe<br/>Clé API requise</p>
</div>
{aiProvider === "mistral" && (
<Badge className="bg-orange-500 text-white text-xs">Actif</Badge>
)}
</button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button
type="button"
onClick={() => setAiProvider("manus")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "manus"
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20 shadow-md"
: "border-muted hover:border-blue-300 bg-background"
}`}
>
{aiProvider === "manus" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-blue-500" />
</div>
)}
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-xl">
<Sparkles className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center">
<p className="font-bold text-base">Manus AI</p>
<p className="text-xs text-muted-foreground mt-1">API Manus Forge<br/>Clé Forge requise</p>
</div>
{aiProvider === "manus" && (
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
)}
</button>
</div>
{/* Mistral config */}
{aiProvider === "mistral" && (
<div className="space-y-4 p-4 bg-orange-50 dark:bg-orange-950/10 rounded-xl border border-orange-200 dark:border-orange-800">
<p className="text-sm font-semibold text-orange-700 dark:text-orange-400 uppercase tracking-wide">Configuration Mistral AI</p>
<div className="space-y-2">
<Label htmlFor="mistralApiKey" className="font-medium">Clé API Mistral</Label>
<div className="relative">
<Input
id="mistralApiKey"
type={showMistralKey ? "text" : "password"}
value={mistralApiKey}
onChange={(e) => setMistralApiKey(e.target.value)}
placeholder="Votre clé API Mistral (ex: 3rCjWwA2...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowMistralKey(!showMistralKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showMistralKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé sur <a href="https://console.mistral.ai" target="_blank" rel="noopener noreferrer" className="text-orange-600 hover:underline">console.mistral.ai</a>. Laissez vide pour utiliser la variable d'environnement MISTRAL_API_KEY du serveur.
</p>
</div>
</div>
)}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */}
{aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
<p className="text-sm font-semibold text-blue-700 dark:text-blue-400 uppercase tracking-wide">Configuration Manus Forge</p>
<div className="space-y-2">
<Label htmlFor="manusForgeApiUrl" className="font-medium">URL de l'API Forge</Label>
<Input
id="manusForgeApiUrl"
type="text"
value={manusForgeApiUrl}
onChange={(e) => setManusForgeApiUrl(e.target.value)}
placeholder="https://forge.manus.im"
className="h-11"
/>
<p className="text-xs text-muted-foreground">Laissez vide pour utiliser la valeur par défaut (https://forge.manus.im)</p>
</div>
<div className="space-y-2">
<Label htmlFor="manusForgeApiKey" className="font-medium">Clé API Forge</Label>
<div className="relative">
<Input
id="manusForgeApiKey"
type={showManusKey ? "text" : "password"}
value={manusForgeApiKey}
onChange={(e) => setManusForgeApiKey(e.target.value)}
placeholder="Votre clé API Manus Forge"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowManusKey(!showManusKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showManusKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Laissez vide pour utiliser la variable d'environnement BUILT_IN_FORGE_API_KEY du serveur.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<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 avancés du modèle d'extraction IA
</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 IA (override)</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 className="space-y-3">
<Label htmlFor="recipientKeywords" className="text-base font-semibold">
Destinataire
</Label>
<Textarea
id="recipientKeywords"
value={recipientKeywords}
onChange={(e) => setRecipientKeywords(e.target.value)}
placeholder="Destinataire, Client, Adressé à, Bill to, Ship to"
rows={2}
className="resize-none"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Mots-clés pour identifier le destinataire/client de la facture
</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="space-y-3">
<Label htmlFor="sftpRecipientFilter" className="text-base font-semibold">
Filtre destinataire
</Label>
<Input
id="sftpRecipientFilter"
value={sftpRecipientFilter}
onChange={(e) => setSftpRecipientFilter(e.target.value)}
placeholder="Ex: DSI, Service Travaux, Itinova... (vide = exporter toutes)"
className="h-11"
/>
<p className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Seules les factures dont le destinataire contient cette valeur seront exportées automatiquement. Laisser vide pour exporter toutes les factures.
</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>
);
}