Checkpoint: Application complète de dématérialisation de facturation avec extraction IA (Mistral), authentification locale + Azure AD, stockage local, et export SFTP.
Fonctionnalités implémentées : ✅ Authentification locale (email/password) + Azure AD + Manus OAuth ✅ Upload drag-and-drop de fichiers PDF avec suivi en temps réel ✅ Extraction automatique avec Mistral AI (OCR + LLM) ✅ Détection de doublons (fournisseur, numéro, date) ✅ Score de qualité d'extraction (0-100) ✅ Tableau de bord avec statistiques ✅ Liste des factures avec recherche et filtres ✅ Paramètres utilisateur (LLM, keywords, SFTP) ✅ Historique des imports avec logs détaillés ✅ Gestion des utilisateurs (admin) ✅ Export SFTP manuel/automatique ✅ Stockage local avec organisation YYYY-MM ✅ Tests unitaires d'authentification Architecture : - Frontend : React 19 + Vite + TailwindCSS + Radix UI - Backend : Express + tRPC + Drizzle ORM - Base de données : MySQL (6 tables) - IA : Mistral AI pour extraction - Stockage : Local filesystem - Export : SFTP Pages : - Login (choix local/Azure/Manus) - Dashboard (statistiques) - Upload (drag-and-drop) - Invoices (liste avec recherche) - Settings (LLM, keywords, SFTP) - History (logs d'import) - Users (gestion admin)
This commit is contained in:
203
client/src/pages/Upload.tsx
Normal file
203
client/src/pages/Upload.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, 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 { trpc } from "@/lib/trpc";
|
||||
import { Upload as UploadIcon, FileText, Loader2, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function Upload() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [sourceFileId, setSourceFileId] = useState<number | null>(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);
|
||||
},
|
||||
});
|
||||
|
||||
// Poll source file status
|
||||
const { data: sourceFiles } = trpc.sourceFiles.getByIds.useQuery(
|
||||
{ ids: sourceFileId ? [sourceFileId] : [] },
|
||||
{
|
||||
enabled: !!sourceFileId,
|
||||
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 sourceFile = sourceFiles && sourceFiles.length > 0 ? sourceFiles[0] : undefined;
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = 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);
|
||||
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<HTMLInputElement>) => {
|
||||
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 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");
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Importer des factures</h1>
|
||||
<p className="text-gray-500 mt-1">Uploadez un fichier PDF contenant une ou plusieurs factures</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload de fichier</CardTitle>
|
||||
<CardDescription>Glissez-déposez un fichier PDF ou cliquez pour sélectionner</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-12 text-center transition-colors
|
||||
${isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : "cursor-pointer"}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="file-input"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<label htmlFor="file-input" className="cursor-pointer">
|
||||
<UploadIcon className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium mb-2">
|
||||
{uploading ? "Upload en cours..." : "Glissez-déposez un fichier PDF ici"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">ou cliquez pour sélectionner un fichier</p>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Processing status */}
|
||||
{sourceFile && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Traitement en cours</CardTitle>
|
||||
<CardDescription>{sourceFile.fileName}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{sourceFile.processingStatus === "processing" && (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium">Extraction en cours...</div>
|
||||
<div className="text-sm text-gray-500">{sourceFile.processingProgress}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sourceFile.processingStatus === "completed" && (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-green-600">Traitement terminé</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{sourceFile.totalInvoicesDetected} facture(s) détectée(s)
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sourceFile.processingStatus === "error" && (
|
||||
<>
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<div className="font-medium text-red-600">Erreur de traitement</div>
|
||||
<div className="text-sm text-gray-500">{sourceFile.processingProgress}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sourceFile.processingStatus === "completed" && (
|
||||
<Button onClick={handleViewInvoices} className="w-full">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Voir les factures
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user