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(null); const [fileData, setFileData] = useState(null); const [fileName, setFileName] = useState(""); const [mimeType, setMimeType] = useState("image/png"); const fileInputRef = useRef(null); const canvasRef = useRef(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 | React.TouchEvent, 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 | React.TouchEvent) => { e.preventDefault(); const canvas = canvasRef.current; if (!canvas) return; isDrawingRef.current = true; lastPosRef.current = getPos(e, canvas); }, [getPos]); const draw = useCallback((e: React.MouseEvent | React.TouchEvent) => { 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) => { 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 (
{/* Header */}
Gestion des signatures Ajoutez les signatures des responsables pour les documents officiels
{/* Add form */}

Ajouter une signature

{/* Nom / Prénom */}
setFirstName(e.target.value)} placeholder="Ex : Jean" className="h-10" />
setLastName(e.target.value)} placeholder="Ex : Dupont" className="h-10" />
{/* Mode selector */}
{/* Draw mode */} {mode === "draw" && (
Signez ici
)} {/* File mode */} {mode === "file" && (
fileInputRef.current?.click()} > {previewUrl ? ( Aperçu signature ) : (
Cliquez pour sélectionner une image (PNG, JPG, GIF...) Taille max : 2 Mo
)}
{previewUrl && ( )}
)}
{/* Signatures list */} {isLoading ? (
) : !signatures || signatures.length === 0 ? (

Aucune signature enregistrée

) : (

{signatures.length} signature{signatures.length > 1 ? "s" : ""} enregistrée{signatures.length > 1 ? "s" : ""}

{signatures.map((sig) => (
{/* Signature image */}
{`Signature { (e.target as HTMLImageElement).src = ""; }} />
{/* Name */}
{sig.firstName} {sig.lastName}
{/* Date */}

Ajoutée le {new Date(sig.createdAt).toLocaleDateString("fr-FR")}

{/* Delete button */}
))}
)}
); } 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 (
); } const requiredCount = fields?.filter(f => f.isRequired === 1).length || 0; const totalCount = fields?.length || 0; return (

Configuration actuelle

{requiredCount} champ{requiredCount > 1 ? 's' : ''} obligatoire{requiredCount > 1 ? 's' : ''} sur {totalCount}

Score 100%

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.

Champ Statut Obligatoire {fields?.map((field) => ( {field.displayName} {field.isRequired === 1 ? ( Obligatoire ) : ( Optionnel )}
handleToggle(field.fieldName, field.isRequired)} disabled={updateFieldMutation.isPending} className="data-[state=checked]:bg-green-500 data-[state=checked]:border-green-500" />
))}
); } 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 (

Chargement des paramètres...

); } const isSftpConfigured = sftpHost && sftpUsername; return (
{/* Header */}

Paramètres

Configurez l'extraction et l'export de vos factures

{/* Tabs Navigation */} Intelligence AI Mots-clés Export SFTP {isSftpConfigured && ( Configuré )} Signatures {/* LLM Tab */}
Configuration du modèle AI Paramètres du modèle d'extraction Mistral AI
setLlmModel(e.target.value)} placeholder="mistral-large-latest" className="h-11" />

Nom du modèle Mistral à utiliser pour l'extraction

setLlmLogsRetentionMonths(parseInt(e.target.value) || 3)} min={1} max={12} className="h-11" />

Durée de conservation des logs LLM (1-12 mois)

Champs de détection Configurez quels champs sont obligatoires pour un score de 100%
{/* Keywords Tab */}
Mots-clés personnalisés Améliorez la détection en ajoutant vos propres mots-clés (séparés par des virgules)