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:
@@ -9,6 +9,7 @@ import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Upload from "./pages/Upload";
|
||||
import Invoices from "./pages/Invoices";
|
||||
import InvoiceDetail from "./pages/InvoiceDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
import History from "./pages/History";
|
||||
import Users from "./pages/Users";
|
||||
@@ -21,6 +22,7 @@ function Router() {
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/upload" component={Upload} />
|
||||
<Route path="/invoices" component={Invoices} />
|
||||
<Route path="/invoices/:id" component={InvoiceDetail} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/history" component={History} />
|
||||
<Route path="/users" component={Users} />
|
||||
|
||||
346
client/src/pages/InvoiceDetail.tsx
Normal file
346
client/src/pages/InvoiceDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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 { useLocation } from "wouter";
|
||||
|
||||
@@ -22,9 +23,40 @@ export default function Invoices() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
|
||||
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({
|
||||
onSuccess: (data) => {
|
||||
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) => {
|
||||
// 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;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
@@ -76,13 +135,22 @@ export default function Invoices() {
|
||||
|
||||
const handleExport = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error("Veuillez sélectionner au moins une facture");
|
||||
toast.error("Veuillez s\u00e9lectionner au moins une facture");
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
switch (status) {
|
||||
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>
|
||||
</div>
|
||||
<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
|
||||
onClick={handleExport}
|
||||
disabled={selectedIds.length === 0 || exportMutation.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter ({selectedIds.length})
|
||||
PDF ({selectedIds.length})
|
||||
</Button>
|
||||
<Button onClick={() => setLocation("/upload")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
@@ -149,6 +226,41 @@ export default function Invoices() {
|
||||
</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 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||
@@ -184,7 +296,12 @@ export default function Invoices() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<button
|
||||
onClick={() => setLocation(`/invoices/${invoice.id}`)}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{invoice.supplierName || "Inconnu"}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{invoice.invoiceNumber || "-"}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"wouter": "^3.3.5",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
72
pnpm-lock.yaml
generated
72
pnpm-lock.yaml
generated
@@ -232,6 +232,9 @@ importers:
|
||||
wouter:
|
||||
specifier: ^3.3.5
|
||||
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
@@ -2505,6 +2508,10 @@ packages:
|
||||
add@2.0.6:
|
||||
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:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2594,6 +2601,10 @@ packages:
|
||||
ccount@2.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2639,6 +2650,10 @@ packages:
|
||||
react: ^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:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2700,6 +2715,11 @@ packages:
|
||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3199,6 +3219,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
@@ -4197,6 +4221,10 @@ packages:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==}
|
||||
engines: {node: '>=18.20.4'}
|
||||
@@ -4572,11 +4600,24 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
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:
|
||||
resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==}
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -7093,6 +7134,8 @@ snapshots:
|
||||
|
||||
add@2.0.6: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -7192,6 +7235,11 @@ snapshots:
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -7244,6 +7292,8 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -7297,6 +7347,8 @@ snapshots:
|
||||
nan: 2.24.0
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@@ -7788,6 +7840,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
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: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
ssh2-sftp-client@12.0.1:
|
||||
dependencies:
|
||||
concat-stream: 2.0.0
|
||||
@@ -9515,6 +9573,10 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1):
|
||||
dependencies:
|
||||
mitt: 3.0.1
|
||||
@@ -9522,6 +9584,16 @@ snapshots:
|
||||
regexparam: 3.0.0
|
||||
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@5.0.0: {}
|
||||
|
||||
@@ -424,6 +424,36 @@ export const appRouter = router({
|
||||
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
|
||||
.input(z.object({ invoiceIds: z.array(z.number()) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
10
todo.md
10
todo.md
@@ -86,3 +86,13 @@
|
||||
- [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] 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
|
||||
|
||||
Reference in New Issue
Block a user