diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/src/App.tsx b/client/src/App.tsx index 5c7a610..8fb5220 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,31 +5,35 @@ import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Upload from "./pages/Upload"; +import Invoices from "./pages/Invoices"; +import Settings from "./pages/Settings"; +import History from "./pages/History"; +import Users from "./pages/Users"; function Router() { - // make sure to consider if you need authentication for certain routes return ( - - - {/* Final fallback route */} + + + + + + + + + ); } -// NOTE: About Theme -// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css -// to keep consistent foreground/background color across components -// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook - function App() { return ( - + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 0bf7437..b601e07 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,15 +21,19 @@ import { } from "@/components/ui/sidebar"; import { getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { Button } from "./ui/button"; const menuItems = [ - { icon: LayoutDashboard, label: "Page 1", path: "/" }, - { icon: Users, label: "Page 2", path: "/some-path" }, + { icon: LayoutDashboard, label: "Tableau de bord", path: "/dashboard" }, + { icon: Upload, label: "Importer", path: "/upload" }, + { icon: FileText, label: "Factures", path: "/invoices" }, + { icon: History, label: "Historique", path: "/history" }, + { icon: Settings, label: "Param\u00e8tres", path: "/settings" }, + { icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true }, ]; const SIDEBAR_WIDTH_KEY = "sidebar-width"; @@ -180,7 +184,7 @@ function DashboardLayoutContent({ - {menuItems.map(item => { + {menuItems.filter(item => !item.adminOnly || user?.role === "admin").map(item => { const isActive = location === item.path; return ( diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..bc2756e --- /dev/null +++ b/client/src/pages/Dashboard.tsx @@ -0,0 +1,118 @@ +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { trpc } from "@/lib/trpc"; +import { FileText, CheckCircle, Clock, AlertCircle } from "lucide-react"; + +export default function Dashboard() { + const { data: stats, isLoading } = trpc.invoices.getStats.useQuery(); + const { data: invoices } = trpc.invoices.list.useQuery(); + + const recentInvoices = invoices?.slice(0, 5) || []; + + return ( + +
+
+

Tableau de bord

+

Vue d'ensemble de vos factures

+
+ + {/* Stats cards */} +
+ + + Total + + + +
{stats?.total || 0}
+

Factures au total

+
+
+ + + + Complétées + + + +
{stats?.completed || 0}
+

Extraction réussie

+
+
+ + + + En cours + + + +
{stats?.processing || 0}
+

En traitement

+
+
+ + + + Erreurs + + + +
{stats?.error || 0}
+

Échecs d'extraction

+
+
+
+ + {/* Recent invoices */} + + + Factures récentes + Les 5 dernières factures importées + + + {recentInvoices.length === 0 ? ( +
+ +

Aucune facture pour le moment

+

Commencez par importer un fichier PDF

+
+ ) : ( +
+ {recentInvoices.map((invoice) => ( +
+
+
{invoice.supplierName || "Fournisseur inconnu"}
+
+ {invoice.invoiceNumber || "N° inconnu"} • {invoice.invoiceDate ? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR") : "Date inconnue"} +
+
+
+
+ {invoice.totalAmount ? `${parseFloat(invoice.totalAmount).toFixed(2)} €` : "-"} +
+
+ {invoice.status === "completed" && ( + Complété + )} + {invoice.status === "processing" && ( + En cours + )} + {invoice.status === "error" && ( + Erreur + )} +
+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx new file mode 100644 index 0000000..a050289 --- /dev/null +++ b/client/src/pages/History.tsx @@ -0,0 +1,94 @@ +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc"; +import { History as HistoryIcon, FileText } from "lucide-react"; + +export default function History() { + const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); + + return ( + +
+
+

Historique des imports

+

Consultez l'historique de tous vos imports de factures

+
+ + + + Logs d'import + Détails de chaque import avec statistiques + + + {isLoading ? ( +
Chargement...
+ ) : logs && logs.length > 0 ? ( + + + + Fichier + Date + Détectées + Importées + Doublons + Erreurs + + + + {logs.map((log) => ( + + {log.fileName} + + {new Date(log.importedAt).toLocaleString("fr-FR")} + + + {log.totalInvoicesDetected} + + + + {log.invoicesImported} + + + + {log.duplicatesIgnored > 0 ? ( + + {log.duplicatesIgnored} + + ) : ( + - + )} + + + {log.errors > 0 ? ( + + {log.errors} + + ) : ( + - + )} + + + ))} + +
+ ) : ( +
+ +

Aucun historique d'import

+
+ )} +
+
+
+
+ ); +} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 8b4b79b..a635e0d 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -1,31 +1,33 @@ +import { useEffect } from "react"; import { useAuth } from "@/_core/hooks/useAuth"; +import { Loader2, FileText } from "lucide-react"; +import { useLocation } from "wouter"; import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; -import { getLoginUrl } from "@/const"; -import { Streamdown } from 'streamdown'; -/** - * All content in this page are only for example, replace with your own feature implementation - * When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls - */ export default function Home() { - // The userAuth hooks provides authentication state - // To implement login/logout functionality, simply call logout() or redirect to getLoginUrl() - let { user, loading, error, isAuthenticated, logout } = useAuth(); + const [, setLocation] = useLocation(); + const { user, loading } = useAuth(); - // If theme is switchable in App.tsx, we can implement theme toggling like this: - // const { theme, toggleTheme } = useTheme(); + useEffect(() => { + if (!loading) { + if (user) { + setLocation("/dashboard"); + } else { + setLocation("/login"); + } + } + }, [user, loading, setLocation]); return ( -
-
- {/* Example: lucide-react for icons */} - - Example Page - {/* Example: Streamdown for markdown rendering */} - Any **markdown** content - -
+
+
+
+ +
+

Invoice Analyzer

+

Dématérialisation de la facturation

+ +
); } diff --git a/client/src/pages/Invoices.tsx b/client/src/pages/Invoices.tsx new file mode 100644 index 0000000..f931644 --- /dev/null +++ b/client/src/pages/Invoices.tsx @@ -0,0 +1,169 @@ +import { useState } 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 { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { trpc } from "@/lib/trpc"; +import { Search, Eye, Trash2, FileText } from "lucide-react"; +import { toast } from "sonner"; +import { useLocation } from "wouter"; + +export default function Invoices() { + const [, setLocation] = useLocation(); + const [searchQuery, setSearchQuery] = useState(""); + const { data: invoices, isLoading } = trpc.invoices.list.useQuery(); + const utils = trpc.useUtils(); + + const deleteMutation = trpc.invoices.delete.useMutation({ + onSuccess: () => { + toast.success("Facture supprimée"); + utils.invoices.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Erreur lors de la suppression"); + }, + }); + + const filteredInvoices = invoices?.filter((inv) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + inv.supplierName?.toLowerCase().includes(query) || + inv.invoiceNumber?.toLowerCase().includes(query) + ); + }); + + const handleDelete = (id: number) => { + if (confirm("Êtes-vous sûr de vouloir supprimer cette facture ?")) { + deleteMutation.mutate({ id }); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "completed": + return Complété; + case "processing": + return En cours; + case "error": + return Erreur; + default: + return {status}; + } + }; + + const getQualityBadge = (score: number | null) => { + if (score === null) return -; + if (score >= 80) return {score}; + if (score >= 60) return {score}; + return {score}; + }; + + return ( + +
+
+
+

Factures

+

Gérez toutes vos factures importées

+
+ +
+ + + + Liste des factures + +
+ + setSearchQuery(e.target.value)} + className="max-w-md" + /> +
+
+
+ + {isLoading ? ( +
Chargement...
+ ) : filteredInvoices && filteredInvoices.length > 0 ? ( + + + + Fournisseur + N° Facture + Date + Montant + Score + Statut + Actions + + + + {filteredInvoices.map((invoice) => ( + + + {invoice.supplierName || "Inconnu"} + + {invoice.invoiceNumber || "-"} + + {invoice.invoiceDate + ? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR") + : "-"} + + + {invoice.totalAmount + ? `${parseFloat(invoice.totalAmount).toFixed(2)} €` + : "-"} + + {getQualityBadge(invoice.qualityScore)} + {getStatusBadge(invoice.status)} + +
+ + +
+
+
+ ))} +
+
+ ) : ( +
+ +

Aucune facture trouvée

+
+ )} +
+
+
+
+ ); +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..f7cb642 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, FileText } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { useLocation } from "wouter"; +import { getLoginUrl } from "@/const"; + +export default function Login() { + const [, setLocation] = useLocation(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showLocalLogin, setShowLocalLogin] = useState(false); + + const loginMutation = trpc.auth.loginLocal.useMutation({ + onSuccess: () => { + toast.success("Connexion réussie"); + window.location.href = "/"; + }, + onError: (error) => { + toast.error(error.message || "Erreur de connexion"); + }, + }); + + const { data: azureAdAvailable } = trpc.auth.isAzureAdAvailable.useQuery(); + + const handleLocalLogin = (e: React.FormEvent) => { + e.preventDefault(); + loginMutation.mutate({ email, password }); + }; + + const handleManusLogin = () => { + window.location.href = getLoginUrl(); + }; + + const handleAzureLogin = () => { + // TODO: Implement Azure AD login flow + toast.info("Azure AD login coming soon"); + }; + + if (!showLocalLogin) { + return ( +
+ + +
+ +
+ Invoice Analyzer + Choisissez votre méthode de connexion +
+ + + + {azureAdAvailable?.available && ( + + )} + + + +
+
+ ); + } + + return ( +
+ + +
+ +
+ Connexion locale + Connectez-vous avec votre email et mot de passe +
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + + + +
+
+
+
+ ); +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 0000000..9ff5c8a --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,319 @@ +import { useState, 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 { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { trpc } from "@/lib/trpc"; +import { Loader2, Save, CheckCircle } from "lucide-react"; +import { toast } from "sonner"; + +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 [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 || ""); + 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"); + 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, + sftpHost, + sftpPort, + sftpUsername, + sftpPassword, + sftpRemotePath, + sftpAutoExport: sftpAutoExport ? 1 : 0, + llmLogsRetentionMonths, + }); + }; + + const handleTestSftp = () => { + testSftpMutation.mutate(); + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

Paramètres

+

Configurez l'extraction et l'export de vos factures

+
+ + {/* LLM Configuration */} + + + Configuration LLM + Paramètres du modèle d'extraction Mistral AI + + +
+ + setLlmModel(e.target.value)} + placeholder="mistral-large-latest" + /> +

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

+
+ +
+ + setLlmLogsRetentionMonths(parseInt(e.target.value) || 3)} + min={1} + max={12} + /> +

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

+
+
+
+ + {/* Keywords Configuration */} + + + Mots-clés personnalisés + + Mots-clés pour améliorer la détection des champs (séparés par des virgules) + + + +
+ +