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:
Manus
2026-01-08 06:02:07 -05:00
parent 205b6061ef
commit 5a01860aba
31 changed files with 4644 additions and 99 deletions

View 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>
);
}