diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index f19413f..b96d20c 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -62,8 +62,8 @@ const menuStructure: MenuItem[] = [ label: "Configuration", color: "from-orange-500 to-amber-500", children: [ - { icon: Settings, label: "Paramètres", path: "/settings" }, - { icon: Download, label: "Paramètres d'import", path: "/import-settings" }, + { icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" }, + { icon: Download, label: "Paramètres import / export", path: "/import-settings" }, { icon: List, label: "Administration des listes", path: "/lists-admin" }, { icon: Zap, label: "Automatismes", path: "/automation-rules" }, { icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true }, diff --git a/client/src/pages/BapHistory.tsx b/client/src/pages/BapHistory.tsx index 367e894..5e9b8e6 100644 --- a/client/src/pages/BapHistory.tsx +++ b/client/src/pages/BapHistory.tsx @@ -12,14 +12,12 @@ import { CheckSquare, Search, Trash2, - ExternalLink, FolderOpen, Monitor, Calendar, - Building2, User, - FileText, Download, + RefreshCw, } from "lucide-react"; // Helper : télécharge un PDF annoté BAP depuis son URL de stockage @@ -193,15 +191,7 @@ export default function BapHistory() { Date validation - -
- - Fournisseur -
-
N° Facture - Montant - Type achat
@@ -210,8 +200,8 @@ export default function BapHistory() { Service Signature - Export - Actions + PDF + Actions @@ -220,31 +210,9 @@ export default function BapHistory() { {formatDate(entry.validatedAt)} - - {entry.supplierName || "—"} - {entry.invoiceNumber || "—"} - - {formatAmount(entry.totalAmount)} - - - {entry.typeAchat ? ( - - {entry.typeAchat.toUpperCase()} - - ) : ( - "—" - )} - {entry.recipientName || ( TOUS @@ -263,7 +231,7 @@ export default function BapHistory() { )} - {entry.exportMode === "browser" && entry.pdfUrl ? ( + {entry.pdfUrl ? (
- ) : entry.exportMode === "folder" && entry.exportPath ? ( + ) : entry.exportPath ? (
@@ -297,15 +265,31 @@ export default function BapHistory() { )} - +
+ {/* Bouton Réexporter */} + {entry.pdfUrl && ( + + )} + {/* Bouton Supprimer */} + +
))} diff --git a/client/src/pages/ImportSettings.tsx b/client/src/pages/ImportSettings.tsx index 2a1a545..519e3ad 100644 --- a/client/src/pages/ImportSettings.tsx +++ b/client/src/pages/ImportSettings.tsx @@ -40,8 +40,12 @@ export default function ImportSettings() { // Export folder const [exportFolder, setExportFolder] = useState(""); - // BAP export mode - const [bapExportMode, setBapExportMode] = useState<"browser" | "folder">("browser"); + // BAP export mode : les deux peuvent être actifs simultanément + const [bapExportBrowser, setBapExportBrowser] = useState(true); + const [bapExportFolder, setBapExportFolder] = useState(false); + const [exportFolderType, setExportFolderType] = useState<"local" | "teams" | "sharepoint">("local"); + // Compat. ancienne valeur + const [bapExportMode, setBapExportMode] = useState<"browser" | "folder" | "both">("browser"); // Initialize form with settings from database useEffect(() => { @@ -57,10 +61,25 @@ export default function ImportSettings() { setEmailImportPort(settings.emailImportPort || 993); setEmailImportFrequency(settings.emailImportFrequency || 30); setExportFolder(settings.exportFolder || ""); - setBapExportMode((settings.bapExportMode as "browser" | "folder") || "browser"); + const savedMode = (settings.bapExportMode as string) || "browser"; + // Compat : ancienne valeur unique -> dériver les deux switches + setBapExportBrowser(savedMode === "browser" || savedMode === "both"); + setBapExportFolder(savedMode === "folder" || savedMode === "both"); + setBapExportMode(savedMode as "browser" | "folder" | "both"); + // Lire le type de dossier depuis exportFolder (préfixe teams:// ou sharepoint://) + if ((settings.exportFolder || "").startsWith("teams://")) setExportFolderType("teams"); + else if ((settings.exportFolder || "").startsWith("sharepoint://")) setExportFolderType("sharepoint"); + else setExportFolderType("local"); } }, [settings]); + // Calculer le bapExportMode à sauvegarder depuis les deux switches + const computedExportMode = (): "browser" | "folder" | "both" => { + if (bapExportBrowser && bapExportFolder) return "both"; + if (bapExportFolder) return "folder"; + return "browser"; + }; + const handleSave = async () => { try { await updateMutation.mutateAsync({ @@ -75,7 +94,7 @@ export default function ImportSettings() { emailImportPort: emailImportPort, emailImportFrequency: emailImportFrequency, exportFolder: exportFolder || null, - bapExportMode: bapExportMode, + bapExportMode: computedExportMode() as "browser" | "folder", }); toast.success("Paramètres enregistrés avec succès"); @@ -106,10 +125,10 @@ export default function ImportSettings() {

- Paramètres de réception + Paramètres import / export

- Configurez les différentes méthodes d'importation de factures + Configurez les méthodes d'importation et les options d'export des factures BAP

@@ -473,79 +492,149 @@ export default function ImportSettings() { - {/* Export Folder & BAP Export Mode */} + {/* Export des factures BAP */}
- +
Export des factures BAP - Configurez le mode d'export et le dossier de destination pour les factures validées BAP + Configurez les modes d'export pour les factures validées BAP (les deux modes peuvent être actifs simultanément)
- {/* BAP Export Mode */} -
- -
- - + + {/* Switch : Ouvrir dans le navigateur */} +
+
+
+ +
+
+ +

Le PDF s'ouvre directement dans un nouvel onglet

+
+
- {/* Export Folder (shown for both modes but required for folder mode) */} -
- - setExportFolder(e.target.value)} - className="h-11" + {/* Switch : Enregistrer dans un dossier */} +
+
+
+ +
+
+ +

Le PDF est copié dans le dossier configuré ci-dessous

+
+
+ -

- {bapExportMode === "folder" - ? "Chemin absolu obligatoire du dossier où les PDF BAP seront enregistrés" - : "Chemin optionnel du dossier d'export (utilisé pour l'export SFTP et l'export groupé)"} -

+ + {/* Configuration du dossier (visible si bapExportFolder actif) */} + {bapExportFolder && ( +
+ + {/* Type de dossier */} +
+ +
+ + + +
+
+ + {/* Chemin du dossier */} +
+ +
+ setExportFolder(e.target.value)} + className="h-11 flex-1" + /> +
+

+ {exportFolderType === "local" && "Chemin absolu du dossier local où les PDF BAP seront enregistrés (exécuté côté serveur)"} + {exportFolderType === "teams" && "Format : teams://NomEquipe/Chemin/Dossier — le PDF sera copié dans le canal Teams correspondant"} + {exportFolderType === "sharepoint" && "URL complète du dossier SharePoint de destination"} +

+
+
+ )} + + {/* Résumé de la configuration */} +
+ Configuration active : + {!bapExportBrowser && !bapExportFolder + ? Aucun mode sélectionné — le téléchargement direct restera disponible + : [ + bapExportBrowser && "Ouverture navigateur", + bapExportFolder && `Enregistrement ${exportFolderType === "local" ? "dossier local" : exportFolderType === "teams" ? "Teams" : "SharePoint"}`, + ].filter(Boolean).join(" + ") + } +
+ diff --git a/client/src/pages/Upload.tsx b/client/src/pages/Upload.tsx index 902edcb..d33f70a 100644 --- a/client/src/pages/Upload.tsx +++ b/client/src/pages/Upload.tsx @@ -1,47 +1,128 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import DashboardLayout from "@/components/DashboardLayout"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { trpc } from "@/lib/trpc"; -import { Upload as UploadIcon, FileText, Loader2, CheckCircle, AlertCircle } from "lucide-react"; +import { + Upload as UploadIcon, + FileText, + Loader2, + CheckCircle, + AlertCircle, + FolderOpen, + File, + X, + ChevronRight, +} from "lucide-react"; import { toast } from "sonner"; import { useLocation } from "wouter"; +interface UploadedFile { + name: string; + sourceFileId: number; + status: "pending" | "processing" | "completed" | "error"; + progress?: string; + invoicesDetected?: number; +} + export default function Upload() { const [, setLocation] = useLocation(); const [isDragging, setIsDragging] = useState(false); - const [uploading, setUploading] = useState(false); - const [sourceFileId, setSourceFileId] = useState(null); + const [uploadMode, setUploadMode] = useState<"file" | "folder">("file"); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); - const uploadMutation = trpc.invoices.upload.useMutation({ - onSuccess: (data) => { - setSourceFileId(data.sourceFileId); - toast.success("Fichier uploadé avec succès"); - }, - onError: (error) => { - toast.error(error.message || "Erreur lors de l'upload"); - setUploading(false); - }, - }); + const uploadMutation = trpc.invoices.upload.useMutation(); + + // Poll pour tous les fichiers en cours + const pendingIds = uploadedFiles + .filter((f) => f.status === "processing" || f.status === "pending") + .map((f) => f.sourceFileId); - // Poll source file status const { data: sourceFiles } = trpc.sourceFiles.getByIds.useQuery( - { ids: sourceFileId ? [sourceFileId] : [] }, + { ids: pendingIds }, { - enabled: !!sourceFileId, + enabled: pendingIds.length > 0, refetchInterval: (query) => { const data = query.state.data; if (!data || data.length === 0) return false; - const file = data[0]; - if (!file || file.processingStatus === "completed" || file.processingStatus === "error") { - return false; - } - return 2000; // Poll every 2 seconds + const allDone = data.every( + (f) => !f || f.processingStatus === "completed" || f.processingStatus === "error" + ); + return allDone ? false : 2000; }, } ); - const sourceFile = sourceFiles && sourceFiles.length > 0 ? sourceFiles[0] : undefined; + // Mettre à jour les statuts depuis le polling + useEffect(() => { + if (!sourceFiles || sourceFiles.length === 0) return; + sourceFiles.forEach((sf) => { + if (!sf) return; + setUploadedFiles((prev) => + prev.map((f) => { + if (f.sourceFileId !== sf.id) return f; + if (f.status === sf.processingStatus) return f; + return { + ...f, + status: (sf.processingStatus ?? "pending") as UploadedFile["status"], + progress: sf.processingProgress ?? undefined, + invoicesDetected: sf.totalInvoicesDetected ?? undefined, + }; + }) + ); + }); + }, [sourceFiles]); + + const uploadFile = async (file: File): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = async () => { + const base64 = (reader.result as string).split(",")[1]; + try { + const data = await uploadMutation.mutateAsync({ + fileName: file.name, + fileData: base64!, + }); + resolve(data.sourceFileId); + } catch (err: any) { + toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`); + resolve(null); + } + }; + reader.readAsDataURL(file); + }); + }; + + const handleFiles = async (files: File[]) => { + const pdfFiles = files.filter((f) => f.type === "application/pdf"); + if (pdfFiles.length === 0) { + toast.error("Aucun fichier PDF trouvé"); + return; + } + if (pdfFiles.length < files.length) { + toast.warning(`${files.length - pdfFiles.length} fichier(s) ignoré(s) (non PDF)`); + } + + setIsUploading(true); + toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`); + + for (const file of pdfFiles) { + const sourceFileId = await uploadFile(file); + if (sourceFileId !== null) { + setUploadedFiles((prev) => [ + ...prev, + { name: file.name, sourceFileId, status: "processing" }, + ]); + } + } + + setIsUploading(false); + toast.success(`${pdfFiles.length} fichier(s) envoyé(s) — traitement en cours`); + }; const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -53,70 +134,79 @@ export default function Upload() { setIsDragging(false); }, []); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + handleFiles(files); + }, + [uploadMode] + ); - const files = Array.from(e.dataTransfer.files); - const pdfFile = files.find((f) => f.type === "application/pdf"); - - if (!pdfFile) { - toast.error("Veuillez sélectionner un fichier PDF"); - return; - } - - handleFileUpload(pdfFile); - }, []); - - const handleFileSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (file.type !== "application/pdf") { - toast.error("Veuillez sélectionner un fichier PDF"); - return; - } - - handleFileUpload(file); + const handleFileInputChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) handleFiles(files); + // Reset input + e.target.value = ""; }; - const handleFileUpload = async (file: File) => { - setUploading(true); - setSourceFileId(null); - - try { - // Convert file to base64 - const reader = new FileReader(); - reader.onload = async () => { - const base64 = (reader.result as string).split(",")[1]; - uploadMutation.mutate({ - fileName: file.name, - fileData: base64!, - }); - }; - reader.readAsDataURL(file); - } catch (error) { - toast.error("Erreur lors de la lecture du fichier"); - setUploading(false); - } - }; - - const handleViewInvoices = () => { - setLocation("/invoices"); - }; + const completedCount = uploadedFiles.filter((f) => f.status === "completed").length; + const errorCount = uploadedFiles.filter((f) => f.status === "error").length; + const processingCount = uploadedFiles.filter( + (f) => f.status === "processing" || f.status === "pending" + ).length; return (
+ {/* Header */}

Importer des factures

-

Uploadez un fichier PDF contenant une ou plusieurs factures

+

+ Importez un fichier PDF unique ou tout un dossier de factures +

+ {/* Mode selector */} +
+ + +
+ + {/* Drop zone */} - Upload de fichier - Glissez-déposez un fichier PDF ou cliquez pour sélectionner + + {uploadMode === "file" ? "Upload de fichier PDF" : "Import d'un dossier"} + + + {uploadMode === "file" + ? "Glissez-déposez un fichier PDF ou cliquez pour sélectionner" + : "Sélectionnez un dossier — tous les fichiers PDF qu'il contient seront importés"} +
{ + if (uploadMode === "file") fileInputRef.current?.click(); + else folderInputRef.current?.click(); + }} > + {/* Hidden inputs */} - + ) : ( + + )} + +

+ {isUploading + ? "Envoi en cours..." + : uploadMode === "file" + ? "Glissez-déposez un fichier PDF ici" + : "Cliquez pour sélectionner un dossier"} +

+

+ {uploadMode === "file" + ? "ou cliquez pour sélectionner un ou plusieurs fichiers PDF" + : "Tous les fichiers PDF du dossier sélectionné seront importés"} +

- {/* Processing status */} - {sourceFile && ( + {/* Liste des fichiers uploadés */} + {uploadedFiles.length > 0 && ( - - Traitement en cours - {sourceFile.fileName} - - -
- {sourceFile.processingStatus === "processing" && ( - <> - -
-
Extraction en cours...
-
{sourceFile.processingProgress}
-
- - )} - {sourceFile.processingStatus === "completed" && ( - <> - -
-
Traitement terminé
-
- {sourceFile.totalInvoicesDetected} facture(s) détectée(s) -
-
- - )} - {sourceFile.processingStatus === "error" && ( - <> - -
-
Erreur de traitement
-
{sourceFile.processingProgress}
-
- - )} + +
+
+ Fichiers importés + + {completedCount > 0 && ( + {completedCount} terminé(s) + )} + {processingCount > 0 && ( + {processingCount} en cours + )} + {errorCount > 0 && ( + {errorCount} erreur(s) + )} + +
+
+ {completedCount > 0 && ( + + )} + +
+
+
+ +
+ {uploadedFiles.map((f, i) => ( +
+ {f.status === "processing" || f.status === "pending" ? ( + + ) : f.status === "completed" ? ( + + ) : ( + + )} +
+

{f.name}

+ {f.status === "processing" && ( +

{f.progress || "Traitement en cours..."}

+ )} + {f.status === "completed" && f.invoicesDetected !== undefined && ( +

+ {f.invoicesDetected} facture(s) détectée(s) +

+ )} + {f.status === "error" && ( +

{f.progress || "Erreur de traitement"}

+ )} +
+ + {f.status === "processing" || f.status === "pending" + ? "En cours" + : f.status === "completed" + ? "Terminé" + : "Erreur"} + +
+ ))}
- - {sourceFile.processingStatus === "completed" && ( - - )}
)}