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:
@@ -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 (
|
||||
<Switch>
|
||||
<Route path={"/"} component={Home} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/upload" component={Upload} />
|
||||
<Route path="/invoices" component={Invoices} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/history" component={History} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
defaultTheme="light"
|
||||
// switchable
|
||||
>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
|
||||
@@ -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({
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{menuItems.map(item => {
|
||||
{menuItems.filter(item => !item.adminOnly || user?.role === "admin").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
|
||||
118
client/src/pages/Dashboard.tsx
Normal file
118
client/src/pages/Dashboard.tsx
Normal file
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tableau de bord</h1>
|
||||
<p className="text-gray-500 mt-1">Vue d'ensemble de vos factures</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Total</CardTitle>
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats?.total || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Factures au total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Complétées</CardTitle>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.completed || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Extraction réussie</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">En cours</CardTitle>
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats?.processing || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">En traitement</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Erreurs</CardTitle>
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats?.error || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Échecs d'extraction</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent invoices */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Factures récentes</CardTitle>
|
||||
<CardDescription>Les 5 dernières factures importées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aucune facture pour le moment</p>
|
||||
<p className="text-sm mt-1">Commencez par importer un fichier PDF</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{invoice.supplierName || "Fournisseur inconnu"}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{invoice.invoiceNumber || "N° inconnu"} • {invoice.invoiceDate ? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR") : "Date inconnue"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">
|
||||
{invoice.totalAmount ? `${parseFloat(invoice.totalAmount).toFixed(2)} €` : "-"}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{invoice.status === "completed" && (
|
||||
<span className="text-green-600">Complété</span>
|
||||
)}
|
||||
{invoice.status === "processing" && (
|
||||
<span className="text-blue-600">En cours</span>
|
||||
)}
|
||||
{invoice.status === "error" && (
|
||||
<span className="text-red-600">Erreur</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
94
client/src/pages/History.tsx
Normal file
94
client/src/pages/History.tsx
Normal file
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Historique des imports</h1>
|
||||
<p className="text-gray-500 mt-1">Consultez l'historique de tous vos imports de factures</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Logs d'import</CardTitle>
|
||||
<CardDescription>Détails de chaque import avec statistiques</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||
) : logs && logs.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fichier</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Détectées</TableHead>
|
||||
<TableHead>Importées</TableHead>
|
||||
<TableHead>Doublons</TableHead>
|
||||
<TableHead>Erreurs</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">{log.fileName}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(log.importedAt).toLocaleString("fr-FR")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.totalInvoicesDetected}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
|
||||
{log.invoicesImported}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.duplicatesIgnored > 0 ? (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">
|
||||
{log.duplicatesIgnored}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.errors > 0 ? (
|
||||
<Badge className="bg-red-100 text-red-800 hover:bg-red-100">
|
||||
{log.errors}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aucun historique d'import</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<main>
|
||||
{/* Example: lucide-react for icons */}
|
||||
<Loader2 className="animate-spin" />
|
||||
Example Page
|
||||
{/* Example: Streamdown for markdown rendering */}
|
||||
<Streamdown>Any **markdown** content</Streamdown>
|
||||
<Button variant="default">Example Button</Button>
|
||||
</main>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-6 w-20 h-20 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Invoice Analyzer</h1>
|
||||
<p className="text-gray-600 mb-6">Dématérialisation de la facturation</p>
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
169
client/src/pages/Invoices.tsx
Normal file
169
client/src/pages/Invoices.tsx
Normal file
@@ -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 <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Complété</Badge>;
|
||||
case "processing":
|
||||
return <Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100">En cours</Badge>;
|
||||
case "error":
|
||||
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Erreur</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityBadge = (score: number | null) => {
|
||||
if (score === null) return <Badge variant="outline">-</Badge>;
|
||||
if (score >= 80) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}</Badge>;
|
||||
if (score >= 60) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}</Badge>;
|
||||
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Factures</h1>
|
||||
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p>
|
||||
</div>
|
||||
<Button onClick={() => setLocation("/upload")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Importer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des factures</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Search className="w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur ou numéro..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||
) : filteredInvoices && filteredInvoices.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fournisseur</TableHead>
|
||||
<TableHead>N° Facture</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Montant</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInvoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">
|
||||
{invoice.supplierName || "Inconnu"}
|
||||
</TableCell>
|
||||
<TableCell>{invoice.invoiceNumber || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{invoice.invoiceDate
|
||||
? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.totalAmount
|
||||
? `${parseFloat(invoice.totalAmount).toFixed(2)} €`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>{getQualityBadge(invoice.qualityScore)}</TableCell>
|
||||
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation(`/invoices/${invoice.id}`)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(invoice.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aucune facture trouvée</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
147
client/src/pages/Login.tsx
Normal file
147
client/src/pages/Login.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Invoice Analyzer</CardTitle>
|
||||
<CardDescription>Choisissez votre méthode de connexion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
onClick={() => setShowLocalLogin(true)}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Connexion locale
|
||||
</Button>
|
||||
|
||||
{azureAdAvailable?.available && (
|
||||
<Button
|
||||
onClick={handleAzureLogin}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Connexion Azure AD
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleManusLogin}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Connexion Manus
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Connexion locale</CardTitle>
|
||||
<CardDescription>Connectez-vous avec votre email et mot de passe</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLocalLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="votre@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Se connecter
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setShowLocalLogin(false)}
|
||||
>
|
||||
Retour aux options de connexion
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
client/src/pages/Settings.tsx
Normal file
319
client/src/pages/Settings.tsx
Normal file
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Paramètres</h1>
|
||||
<p className="text-gray-500 mt-1">Configurez l'extraction et l'export de vos factures</p>
|
||||
</div>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration LLM</CardTitle>
|
||||
<CardDescription>Paramètres du modèle d'extraction Mistral AI</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llmModel">Modèle Mistral</Label>
|
||||
<Input
|
||||
id="llmModel"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder="mistral-large-latest"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Nom du modèle Mistral à utiliser pour l'extraction</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llmLogsRetentionMonths">Rétention des logs LLM (mois)</Label>
|
||||
<Input
|
||||
id="llmLogsRetentionMonths"
|
||||
type="number"
|
||||
value={llmLogsRetentionMonths}
|
||||
onChange={(e) => setLlmLogsRetentionMonths(parseInt(e.target.value) || 3)}
|
||||
min={1}
|
||||
max={12}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Durée de conservation des logs LLM (1-12 mois)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Keywords Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mots-clés personnalisés</CardTitle>
|
||||
<CardDescription>
|
||||
Mots-clés pour améliorer la détection des champs (séparés par des virgules)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoiceNumberKeywords">Numéro de facture</Label>
|
||||
<Textarea
|
||||
id="invoiceNumberKeywords"
|
||||
value={invoiceNumberKeywords}
|
||||
onChange={(e) => setInvoiceNumberKeywords(e.target.value)}
|
||||
placeholder="Référence, Ref facture, Invoice ref"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryNoteKeywords">Bon de livraison</Label>
|
||||
<Textarea
|
||||
id="deliveryNoteKeywords"
|
||||
value={deliveryNoteKeywords}
|
||||
onChange={(e) => setDeliveryNoteKeywords(e.target.value)}
|
||||
placeholder="Livraison, Delivery, Expédition"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderNumberKeywords">Numéro de commande</Label>
|
||||
<Textarea
|
||||
id="orderNumberKeywords"
|
||||
value={orderNumberKeywords}
|
||||
onChange={(e) => setOrderNumberKeywords(e.target.value)}
|
||||
placeholder="Cde client, Référence commande, PO Number"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="supplierKeywords">Fournisseur</Label>
|
||||
<Textarea
|
||||
id="supplierKeywords"
|
||||
value={supplierKeywords}
|
||||
onChange={(e) => setSupplierKeywords(e.target.value)}
|
||||
placeholder="Vendeur, Société, Émetteur"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totalAmountKeywords">Montant total</Label>
|
||||
<Textarea
|
||||
id="totalAmountKeywords"
|
||||
value={totalAmountKeywords}
|
||||
onChange={(e) => setTotalAmountKeywords(e.target.value)}
|
||||
placeholder="Net à payer, Total à régler, Amount due"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SFTP Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration SFTP</CardTitle>
|
||||
<CardDescription>Paramètres d'export vers un serveur SFTP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sftpHost">Hôte SFTP</Label>
|
||||
<Input
|
||||
id="sftpHost"
|
||||
value={sftpHost}
|
||||
onChange={(e) => setSftpHost(e.target.value)}
|
||||
placeholder="sftp.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sftpPort">Port</Label>
|
||||
<Input
|
||||
id="sftpPort"
|
||||
type="number"
|
||||
value={sftpPort}
|
||||
onChange={(e) => setSftpPort(parseInt(e.target.value) || 22)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sftpUsername">Nom d'utilisateur</Label>
|
||||
<Input
|
||||
id="sftpUsername"
|
||||
value={sftpUsername}
|
||||
onChange={(e) => setSftpUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sftpPassword">Mot de passe</Label>
|
||||
<Input
|
||||
id="sftpPassword"
|
||||
type="password"
|
||||
value={sftpPassword}
|
||||
onChange={(e) => setSftpPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sftpRemotePath">Chemin distant</Label>
|
||||
<Input
|
||||
id="sftpRemotePath"
|
||||
value={sftpRemotePath}
|
||||
onChange={(e) => setSftpRemotePath(e.target.value)}
|
||||
placeholder="/invoices"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Les fichiers seront organisés par date: /chemin/YYYY/MM/DD/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftpAutoExport">Export automatique</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Exporter automatiquement les factures après extraction
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sftpAutoExport"
|
||||
checked={sftpAutoExport}
|
||||
onCheckedChange={setSftpAutoExport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestSftp}
|
||||
disabled={testSftpMutation.isPending || !sftpHost}
|
||||
>
|
||||
{testSftpMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending} size="lg">
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Enregistrer les paramètres
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
249
client/src/pages/Users.tsx
Normal file
249
client/src/pages/Users.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { UserPlus, Trash2, Users as UsersIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Users() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<"user" | "admin">("user");
|
||||
|
||||
const { data: users, isLoading } = trpc.admin.getAllUsers.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.admin.createUser.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur créé");
|
||||
utils.admin.getAllUsers.invalidate();
|
||||
setDialogOpen(false);
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setName("");
|
||||
setRole("user");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Erreur lors de la création");
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActiveMutation = trpc.admin.toggleUserActive.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Statut mis à jour");
|
||||
utils.admin.getAllUsers.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Erreur lors de la mise à jour");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.admin.deleteUser.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur supprimé");
|
||||
utils.admin.getAllUsers.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Erreur lors de la suppression");
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate({ email, password, name, role });
|
||||
};
|
||||
|
||||
const handleToggleActive = (userId: number, isActive: number) => {
|
||||
toggleActiveMutation.mutate({ userId, isActive: isActive === 1 ? 0 : 1 });
|
||||
};
|
||||
|
||||
const handleDelete = (userId: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cet utilisateur ?")) {
|
||||
deleteMutation.mutate({ userId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Gestion des utilisateurs</h1>
|
||||
<p className="text-gray-500 mt-1">Créez et gérez les comptes utilisateurs</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Créer un utilisateur
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un nouvel utilisateur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez un compte utilisateur avec authentification locale
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Jean Dupont"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="jean.dupont@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Rôle</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as "user" | "admin")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !email || !password || !name}
|
||||
className="w-full"
|
||||
>
|
||||
Créer l'utilisateur
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des utilisateurs</CardTitle>
|
||||
<CardDescription>Gérez les comptes et les permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Méthode</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Actif</TableHead>
|
||||
<TableHead>Créé le</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name || "-"}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{user.loginMethod}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.role === "admin" ? (
|
||||
<Badge className="bg-purple-100 text-purple-800 hover:bg-purple-100">
|
||||
Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">User</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={user.isActive === 1}
|
||||
onCheckedChange={() => handleToggleActive(user.id, user.isActive)}
|
||||
disabled={toggleActiveMutation.isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(user.createdAt).toLocaleDateString("fr-FR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<UsersIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aucun utilisateur</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user