- {/* Export Folder (shown for both modes but required for folder mode) */}
-
-
- Chemin du dossier d'export
- {bapExportMode === "folder" && * }
-
-
setExportFolder(e.target.value)}
- className="h-11"
+ {/* Switch : Enregistrer dans un dossier */}
+
+
+
+
+
+
+
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 */}
+
+
Type de destination
+
+ setExportFolderType("local")}
+ className={`flex flex-col items-center gap-2 p-3 rounded-xl border-2 transition-all ${
+ exportFolderType === "local"
+ ? "border-orange-500 bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-300"
+ : "border-muted hover:border-muted-foreground/40 text-muted-foreground"
+ }`}
+ >
+
+ Dossier local
+
+ setExportFolderType("teams")}
+ className={`flex flex-col items-center gap-2 p-3 rounded-xl border-2 transition-all ${
+ exportFolderType === "teams"
+ ? "border-purple-500 bg-purple-50 dark:bg-purple-950/30 text-purple-700 dark:text-purple-300"
+ : "border-muted hover:border-muted-foreground/40 text-muted-foreground"
+ }`}
+ >
+
+ Teams
+
+ setExportFolderType("sharepoint")}
+ className={`flex flex-col items-center gap-2 p-3 rounded-xl border-2 transition-all ${
+ exportFolderType === "sharepoint"
+ ? "border-cyan-500 bg-cyan-50 dark:bg-cyan-950/30 text-cyan-700 dark:text-cyan-300"
+ : "border-muted hover:border-muted-foreground/40 text-muted-foreground"
+ }`}
+ >
+
+ SharePoint
+
+
+
+
+ {/* Chemin du dossier */}
+
+
+ {exportFolderType === "local" && "Chemin du dossier local"}
+ {exportFolderType === "teams" && "Chemin du dossier Teams"}
+ {exportFolderType === "sharepoint" && "URL SharePoint / chemin"}
+ *
+
+
+ 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 */}
+
+ setUploadMode("file")}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
+ uploadMode === "file"
+ ? "border-purple-500 bg-purple-50 dark:bg-purple-950/30 text-purple-700 dark:text-purple-300"
+ : "border-muted hover:border-muted-foreground/40 text-muted-foreground"
+ }`}
+ >
+
+ Fichier unique
+
+ setUploadMode("folder")}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
+ uploadMode === "folder"
+ ? "border-blue-500 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300"
+ : "border-muted hover:border-muted-foreground/40 text-muted-foreground"
+ }`}
+ >
+
+ Dossier entier
+
+
+
+ {/* 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 */}
-
+
+
+ {uploadMode === "file" ? (
-
- {uploading ? "Upload en cours..." : "Glissez-déposez un fichier PDF ici"}
-
- ou cliquez pour sélectionner un fichier
-
+ ) : (
+
+ )}
+
+
+ {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 && (
+ setLocation("/invoices")}
+ className="bg-green-600 hover:bg-green-700 text-white"
+ >
+
+ Voir les factures
+
+
+ )}
+ setUploadedFiles([])}
+ >
+
+ Effacer
+
+
+
+
+
+
+ {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" && (
-
-
- Voir les factures
-
- )}
)}