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(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 via callback ref — fires as soon as the element mounts const initCanvas = useCallback((canvas: HTMLCanvasElement | null) => { (canvasRef as React.MutableRefObject).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 | 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 */}
))}
)}
{/* Service → Signature associations */}
); } 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 ( Association Service → Signature
); } if (!departments || departments.length === 0) { return ( Association Service → Signature Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP

Aucun service configuré. Ajoutez des services dans l'onglet Listes d'abord.

); } if (!signatures || signatures.length === 0) { return ( Association Service → Signature Associez une signature à chaque service pour l'apposer automatiquement lors de l'export BAP

Aucune signature enregistrée. Ajoutez des signatures ci-dessus d'abord.

); } return ( Association Service → Signature Associez une signature à chaque service pour l'apposer automatiquement lors de l'export PDF BAP Service Signature associée Aperçu Action {departments.map((dept: { id: number; name: string; userId: number; createdAt: Date }) => { const assoc = getAssociation(dept.name); const currentSigId = assoc?.signatureId ?? 0; return ( {dept.name} {assoc && signatures.find(s => s.id === assoc.signatureId) ? (
s.id === assoc.signatureId)!.imageUrl} alt="aperçu" className="max-h-full max-w-full object-contain" />
) : ( Aucune )}
{assoc && ( )}
); })}
); } 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 [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 (

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 */} {/* AI Engine Selector */}
Moteur IA Choisissez le fournisseur d'intelligence artificielle pour l'extraction des factures
{/* Provider selector */}
{/* Google Gemini */}
{/* Mistral config */} {aiProvider === "mistral" && (

Configuration Mistral AI

setMistralApiKey(e.target.value)} placeholder="Votre clé API Mistral (ex: 3rCjWwA2...)" className="h-11 pr-10" />

Obtenez votre clé sur console.mistral.ai. Laissez vide pour utiliser la variable d'environnement MISTRAL_API_KEY du serveur.

)} {/* Gemini config */} {aiProvider === "gemini" && (

Configuration Google Gemini

Gratuit — 1 500 req/jour
setGeminiApiKey(e.target.value)} placeholder="Votre clé API Google (ex: AIzaSy...)" className="h-11 pr-10" />

Obtenez votre clé gratuite sur{" "} aistudio.google.com/apikey. Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.

Modèle utilisé : gemini-2.0-flash

Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.

)} {/* Manus config */} {aiProvider === "manus" && (

Configuration Manus Forge

setManusForgeApiUrl(e.target.value)} placeholder="https://forge.manus.im" className="h-11" />

Laissez vide pour utiliser la valeur par défaut (https://forge.manus.im)

setManusForgeApiKey(e.target.value)} placeholder="Votre clé API Manus Forge" className="h-11 pr-10" />

Laissez vide pour utiliser la variable d'environnement BUILT_IN_FORGE_API_KEY du serveur.

)}
Configuration du modèle AI Paramètres avancés du modèle d'extraction IA
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)