Checkpoint: Ajout des filtres par statut d'export, export Excel et page de détail avec édition manuelle.

Nouvelles fonctionnalités :

1. Filtres par statut d'export :
   - Boutons de filtre rapide (Tous/Exportés/Non exportés/Erreurs)
   - Compteurs dynamiques pour chaque statut
   - Couleurs distinctives (vert/bleu/rouge)

2. Export Excel :
   - Nouveau bouton "Excel" dans la page Factures
   - Génération automatique de fichier .xlsx avec toutes les métadonnées
   - Colonnes : Fournisseur, N° Facture, Date, N° BL, N° Commande, Montant, Score, Statut, Dates

3. Page de détail de facture (/invoices/:id) :
   - Visualisation PDF intégrée dans iframe
   - Affichage des métadonnées extraites
   - Mode édition avec formulaire complet
   - Recalcul automatique du score de qualité après édition
   - Marquage "Modifié manuellement"
   - Badges de statut et qualité
   - Lien cliquable sur le nom du fournisseur dans la liste

L'application offre maintenant une expérience complète de gestion des factures avec filtrage avancé, exports multiples et édition manuelle.
This commit is contained in:
Manus
2026-01-08 10:19:05 -05:00
parent d24f7c5569
commit 63c2afea1f
7 changed files with 582 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Upload from "./pages/Upload"; import Upload from "./pages/Upload";
import Invoices from "./pages/Invoices"; import Invoices from "./pages/Invoices";
import InvoiceDetail from "./pages/InvoiceDetail";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import History from "./pages/History"; import History from "./pages/History";
import Users from "./pages/Users"; import Users from "./pages/Users";
@@ -21,6 +22,7 @@ function Router() {
<Route path="/dashboard" component={Dashboard} /> <Route path="/dashboard" component={Dashboard} />
<Route path="/upload" component={Upload} /> <Route path="/upload" component={Upload} />
<Route path="/invoices" component={Invoices} /> <Route path="/invoices" component={Invoices} />
<Route path="/invoices/:id" component={InvoiceDetail} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/history" component={History} /> <Route path="/history" component={History} />
<Route path="/users" component={Users} /> <Route path="/users" component={Users} />

View File

@@ -0,0 +1,346 @@
import { useState } from "react";
import { useParams, useLocation } from "wouter";
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 { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { ArrowLeft, Save, FileText } from "lucide-react";
import { toast } from "sonner";
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const [, setLocation] = useLocation();
const invoiceId = parseInt(id || "0");
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
const utils = trpc.useUtils();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
supplierName: "",
invoiceNumber: "",
invoiceDate: "",
deliveryNoteNumber: "",
orderNumber: "",
totalAmount: "",
});
// Initialize form data when invoice loads
useState(() => {
if (invoice) {
setFormData({
supplierName: invoice.supplierName || "",
invoiceNumber: invoice.invoiceNumber || "",
invoiceDate: invoice.invoiceDate
? new Date(invoice.invoiceDate).toISOString().split('T')[0]
: "",
deliveryNoteNumber: invoice.deliveryNoteNumber || "",
orderNumber: invoice.orderNumber || "",
totalAmount: invoice.totalAmount ? invoice.totalAmount.toString() : "",
});
}
});
const updateMutation = trpc.invoices.update.useMutation({
onSuccess: () => {
toast.success("Facture mise à jour avec succès");
setIsEditing(false);
utils.invoices.getById.invalidate({ id: invoiceId });
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la mise à jour");
},
});
const handleSave = () => {
// Calculate new quality score based on filled fields
let filledFields = 0;
let totalFields = 6;
if (formData.supplierName) filledFields++;
if (formData.invoiceNumber) filledFields++;
if (formData.invoiceDate) filledFields++;
if (formData.deliveryNoteNumber) filledFields++;
if (formData.orderNumber) filledFields++;
if (formData.totalAmount) filledFields++;
const newQualityScore = Math.round((filledFields / totalFields) * 100);
updateMutation.mutate({
id: invoiceId,
data: {
supplierName: formData.supplierName || undefined,
invoiceNumber: formData.invoiceNumber || undefined,
invoiceDate: formData.invoiceDate ? new Date(formData.invoiceDate) : undefined,
deliveryNoteNumber: formData.deliveryNoteNumber || undefined,
orderNumber: formData.orderNumber || undefined,
totalAmount: formData.totalAmount ? formData.totalAmount : undefined,
},
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case "exported":
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Exporté</Badge>;
case "not_exported":
return <Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100">Non exporté</Badge>;
case "export_error":
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Erreur export</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getQualityBadge = (score: number | null) => {
if (score === null) return <Badge variant="outline">-</Badge>;
if (score === 100) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>;
if (score >= 80) 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>;
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">Chargement...</div>
</div>
</DashboardLayout>
);
}
if (!invoice) {
return (
<DashboardLayout>
<div className="flex flex-col items-center justify-center h-96">
<FileText className="w-16 h-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-bold mb-2">Facture introuvable</h2>
<p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p>
<Button onClick={() => setLocation("/invoices")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour aux factures
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setLocation("/invoices")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour
</Button>
<div>
<h1 className="text-3xl font-bold">Détail de la facture</h1>
<p className="text-gray-500 mt-1">
{invoice.supplierName || "Fournisseur inconnu"} • {invoice.invoiceNumber || "N° inconnu"}
</p>
</div>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
Enregistrer
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
Modifier
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column - PDF Viewer */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Aperçu du document</CardTitle>
<CardDescription>Fichier PDF de la facture</CardDescription>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden bg-gray-50">
<iframe
src={invoice.fileUrl}
className="w-full h-[800px]"
title="Aperçu de la facture"
/>
</div>
</CardContent>
</Card>
</div>
{/* Right column - Metadata */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Statut</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-sm text-gray-500">Statut d'export</Label>
<div className="mt-1">{getStatusBadge(invoice.exportStatus || "not_exported")}</div>
</div>
<div>
<Label className="text-sm text-gray-500">Score de qualité</Label>
<div className="mt-1">{getQualityBadge(invoice.qualityScore)}</div>
</div>
{invoice.manuallyEdited === 1 && (
<div>
<Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200">
Modifié manuellement
</Badge>
</div>
)}
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Métadonnées</CardTitle>
<CardDescription>
{isEditing ? "Modifiez les champs ci-dessous" : "Informations extraites de la facture"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="supplierName">Fournisseur</Label>
{isEditing ? (
<Input
id="supplierName"
value={formData.supplierName}
onChange={(e) => setFormData({ ...formData, supplierName: e.target.value })}
placeholder="Nom du fournisseur"
/>
) : (
<div className="text-sm mt-1">{invoice.supplierName || "-"}</div>
)}
</div>
<div>
<Label htmlFor="invoiceNumber">Numéro de facture</Label>
{isEditing ? (
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
onChange={(e) => setFormData({ ...formData, invoiceNumber: e.target.value })}
placeholder="N° de facture"
/>
) : (
<div className="text-sm mt-1">{invoice.invoiceNumber || "-"}</div>
)}
</div>
<div>
<Label htmlFor="invoiceDate">Date de facture</Label>
{isEditing ? (
<Input
id="invoiceDate"
type="date"
value={formData.invoiceDate}
onChange={(e) => setFormData({ ...formData, invoiceDate: e.target.value })}
/>
) : (
<div className="text-sm mt-1">
{invoice.invoiceDate
? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR")
: "-"}
</div>
)}
</div>
<div>
<Label htmlFor="deliveryNoteNumber">N° Bon de livraison</Label>
{isEditing ? (
<Input
id="deliveryNoteNumber"
value={formData.deliveryNoteNumber}
onChange={(e) => setFormData({ ...formData, deliveryNoteNumber: e.target.value })}
placeholder="N° de bon de livraison"
/>
) : (
<div className="text-sm mt-1">{invoice.deliveryNoteNumber || "-"}</div>
)}
</div>
<div>
<Label htmlFor="orderNumber">N° de commande</Label>
{isEditing ? (
<Input
id="orderNumber"
value={formData.orderNumber}
onChange={(e) => setFormData({ ...formData, orderNumber: e.target.value })}
placeholder="N° de commande"
/>
) : (
<div className="text-sm mt-1">{invoice.orderNumber || "-"}</div>
)}
</div>
<div>
<Label htmlFor="totalAmount">Montant total</Label>
{isEditing ? (
<Input
id="totalAmount"
type="number"
step="0.01"
value={formData.totalAmount}
onChange={(e) => setFormData({ ...formData, totalAmount: e.target.value })}
placeholder="Montant en €"
/>
) : (
<div className="text-sm mt-1">
{invoice.totalAmount
? `${parseFloat(invoice.totalAmount as string).toFixed(2)}`
: "-"}
</div>
)}
</div>
</CardContent>
</Card>
{/* Info Card */}
<Card>
<CardHeader>
<CardTitle>Informations</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Créé le</span>
<span>{new Date(invoice.createdAt).toLocaleDateString("fr-FR")}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Mis à jour le</span>
<span>{new Date(invoice.updatedAt).toLocaleDateString("fr-FR")}</span>
</div>
{invoice.exportedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Exporté le</span>
<span>{new Date(invoice.exportedAt).toLocaleDateString("fr-FR")}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</DashboardLayout>
);
}

View File

@@ -14,7 +14,8 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Search, FileText, Download } from "lucide-react"; import { Search, FileText, Download, FileSpreadsheet } from "lucide-react";
import * as XLSX from 'xlsx';
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
@@ -22,9 +23,40 @@ export default function Invoices() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
const { data: invoices, isLoading } = trpc.invoices.list.useQuery(); const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const exportExcelMutation = trpc.sftp.exportToExcel.useMutation({
onSuccess: (data) => {
// Generate Excel file
const worksheet = XLSX.utils.json_to_sheet(data.invoices.map(inv => ({
'Fournisseur': inv.supplierName,
'N\u00b0 Facture': inv.invoiceNumber,
'Date': inv.invoiceDate,
'N\u00b0 Bon de livraison': inv.deliveryNoteNumber,
'N\u00b0 Commande': inv.orderNumber,
'Montant': inv.totalAmount,
'Score': inv.qualityScore,
'Statut export': inv.exportStatus,
'Export\u00e9 le': inv.exportedAt,
'Cr\u00e9\u00e9 le': inv.createdAt,
})));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Factures');
// Download Excel file
XLSX.writeFile(workbook, `factures_${new Date().toISOString().split('T')[0]}.xlsx`);
toast.success(`${data.invoices.length} facture(s) export\u00e9e(s) en Excel`);
setSelectedIds([]);
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'export Excel");
},
});
const exportMutation = trpc.sftp.exportToPdf.useMutation({ const exportMutation = trpc.sftp.exportToPdf.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.invoices.length} facture(s) exportée(s) avec succès`); toast.success(`${data.invoices.length} facture(s) exportée(s) avec succès`);
@@ -46,6 +78,33 @@ export default function Invoices() {
}); });
const filteredInvoices = invoices?.filter((inv) => { const filteredInvoices = invoices?.filter((inv) => {
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesSearch = inv.supplierName?.toLowerCase().includes(query) ||
inv.invoiceNumber?.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// Filter by export status
if (statusFilter !== "all") {
if (statusFilter === "exported" && inv.exportStatus !== "exported") return false;
if (statusFilter === "not_exported" && inv.exportStatus !== "not_exported") return false;
if (statusFilter === "export_error" && inv.exportStatus !== "export_error") return false;
}
return true;
});
const statusCounts = {
all: invoices?.length || 0,
exported: invoices?.filter(inv => inv.exportStatus === "exported").length || 0,
not_exported: invoices?.filter(inv => inv.exportStatus === "not_exported").length || 0,
export_error: invoices?.filter(inv => inv.exportStatus === "export_error").length || 0,
};
// Old filter logic (to be removed)
const _oldFilteredInvoices = invoices?.filter((inv) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return ( return (
@@ -76,13 +135,22 @@ export default function Invoices() {
const handleExport = () => { const handleExport = () => {
if (selectedIds.length === 0) { if (selectedIds.length === 0) {
toast.error("Veuillez sélectionner au moins une facture"); toast.error("Veuillez s\u00e9lectionner au moins une facture");
return; return;
} }
exportMutation.mutate({ invoiceIds: selectedIds }); exportMutation.mutate({ invoiceIds: selectedIds });
}; };
const handleExportExcel = () => {
if (selectedIds.length === 0) {
toast.error("Veuillez s\u00e9lectionner au moins une facture");
return;
}
exportExcelMutation.mutate({ invoiceIds: selectedIds });
};
const getExportStatusBadge = (status: string) => { const getExportStatusBadge = (status: string) => {
switch (status) { switch (status) {
case "exported": case "exported":
@@ -119,13 +187,22 @@ export default function Invoices() {
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p> <p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
onClick={handleExportExcel}
disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
variant="outline"
className="border-green-600 text-green-600 hover:bg-green-50"
>
<FileSpreadsheet className="w-4 h-4 mr-2" />
Excel ({selectedIds.length})
</Button>
<Button <Button
onClick={handleExport} onClick={handleExport}
disabled={selectedIds.length === 0 || exportMutation.isPending} disabled={selectedIds.length === 0 || exportMutation.isPending}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Exporter ({selectedIds.length}) PDF ({selectedIds.length})
</Button> </Button>
<Button onClick={() => setLocation("/upload")}> <Button onClick={() => setLocation("/upload")}>
<FileText className="w-4 h-4 mr-2" /> <FileText className="w-4 h-4 mr-2" />
@@ -149,6 +226,41 @@ export default function Invoices() {
</div> </div>
</div> </div>
{/* Status Filters */}
<div className="flex gap-2 mb-4">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
>
Erreurs ({statusCounts.export_error})
</Button>
</div>
{/* Table */} {/* Table */}
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div> <div className="text-center py-8 text-gray-500">Chargement...</div>
@@ -184,7 +296,12 @@ export default function Invoices() {
/> />
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{invoice.supplierName || "Inconnu"} <button
onClick={() => setLocation(`/invoices/${invoice.id}`)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{invoice.supplierName || "Inconnu"}
</button>
</TableCell> </TableCell>
<TableCell>{invoice.invoiceNumber || "-"}</TableCell> <TableCell>{invoice.invoiceNumber || "-"}</TableCell>
<TableCell> <TableCell>

View File

@@ -85,6 +85,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"wouter": "^3.3.5", "wouter": "^3.3.5",
"xlsx": "^0.18.5",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {

72
pnpm-lock.yaml generated
View File

@@ -232,6 +232,9 @@ importers:
wouter: wouter:
specifier: ^3.3.5 specifier: ^3.3.5
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1) version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod: zod:
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.1.12 version: 4.1.12
@@ -2505,6 +2508,10 @@ packages:
add@2.0.6: add@2.0.6:
resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==}
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
aria-hidden@1.2.6: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2594,6 +2601,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2639,6 +2650,10 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2700,6 +2715,11 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3199,6 +3219,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -4197,6 +4221,10 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssh2-sftp-client@12.0.1: ssh2-sftp-client@12.0.1:
resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==} resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
engines: {node: '>=18.20.4'} engines: {node: '>=18.20.4'}
@@ -4572,11 +4600,24 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wouter@3.7.1: wouter@3.7.1:
resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==} resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==}
peerDependencies: peerDependencies:
react: '>=16.8.0' react: '>=16.8.0'
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -7093,6 +7134,8 @@ snapshots:
add@2.0.6: {} add@2.0.6: {}
adler-32@1.3.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -7192,6 +7235,11 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@@ -7244,6 +7292,8 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
codepage@1.15.0: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -7297,6 +7347,8 @@ snapshots:
nan: 2.24.0 nan: 2.24.0
optional: true optional: true
crc-32@1.2.2: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {} csstype@3.1.3: {}
@@ -7788,6 +7840,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
framer-motion@12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1): framer-motion@12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@@ -9139,6 +9193,10 @@ snapshots:
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
ssh2-sftp-client@12.0.1: ssh2-sftp-client@12.0.1:
dependencies: dependencies:
concat-stream: 2.0.0 concat-stream: 2.0.0
@@ -9515,6 +9573,10 @@ snapshots:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2
wmf@1.0.2: {}
word@0.3.0: {}
wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1): wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1):
dependencies: dependencies:
mitt: 3.0.1 mitt: 3.0.1
@@ -9522,6 +9584,16 @@ snapshots:
regexparam: 3.0.0 regexparam: 3.0.0
use-sync-external-store: 1.6.0(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1)
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
yallist@3.1.1: {} yallist@3.1.1: {}
yallist@5.0.0: {} yallist@5.0.0: {}

View File

@@ -424,6 +424,36 @@ export const appRouter = router({
return { success }; return { success };
}), }),
exportToExcel: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id))
);
const validInvoices = invoices.filter(
inv => inv && inv.userId === ctx.user.id
);
// Return invoice data for Excel generation on client side
return {
success: true,
invoices: validInvoices.map(inv => ({
id: inv!.id,
supplierName: inv!.supplierName || '',
invoiceNumber: inv!.invoiceNumber || '',
invoiceDate: inv!.invoiceDate ? new Date(inv!.invoiceDate).toLocaleDateString('fr-FR') : '',
deliveryNoteNumber: inv!.deliveryNoteNumber || '',
orderNumber: inv!.orderNumber || '',
totalAmount: inv!.totalAmount ? parseFloat(inv!.totalAmount as string) : 0,
qualityScore: inv!.qualityScore || 0,
exportStatus: inv!.exportStatus || 'not_exported',
exportedAt: inv!.exportedAt ? new Date(inv!.exportedAt).toLocaleDateString('fr-FR') : '',
createdAt: new Date(inv!.createdAt).toLocaleDateString('fr-FR'),
}))
};
}),
exportToPdf: protectedProcedure exportToPdf: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) })) .input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

10
todo.md
View File

@@ -86,3 +86,13 @@
- [x] Ajouter bouton "Exporter" (actif uniquement pour score 100%) - [x] Ajouter bouton "Exporter" (actif uniquement pour score 100%)
- [x] Implémenter le téléchargement de PDF pour les factures sélectionnées - [x] Implémenter le téléchargement de PDF pour les factures sélectionnées
- [x] Valider que seules les factures avec score 100% peuvent être exportées - [x] Valider que seules les factures avec score 100% peuvent être exportées
## Nouvelles fonctionnalités
- [x] Ajouter des boutons de filtre par statut d'export (Tous/Exportés/Non exportés/Erreurs)
- [x] Créer route tRPC pour export Excel
- [x] Créer page InvoiceDetail avec visualisation des métadonnées
- [x] Ajouter formulaire d'édition manuelle des champs
- [x] Implémenter la mise à jour des métadonnées
- [x] Recalculer le score de qualité après édition manuelle
- [x] Ajouter bouton "Export Excel" dans la page Factures
- [x] Générer fichier Excel avec toutes les métadonnées