Files
demat-facturation/client/src/pages/InvoicesBAP.tsx

1251 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Helper : télécharge un ZIP de plusieurs PDFs annotés BAP
async function downloadBapZip(
files: Array<{ pdfPath: string; filename: string }>
) {
const response = await fetch('/api/download-bap-zip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ files }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error((err as any).error || 'Erreur lors de la génération du ZIP');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const zipFilename = `BAP_export_${new Date().toLocaleDateString('fr-CA')}.zip`;
a.download = zipFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage
// Nommage : AAAA-MM-JJ - Fournisseur - N°Facture.pdf
function downloadBapPdf(
pdfUrl: string,
supplierName?: string,
invoiceNumber?: string | null,
validatedAt?: Date | string | null
) {
const fallback = pdfUrl.split('/').pop() || 'BAP.pdf';
const datePart = validatedAt
? new Date(validatedAt).toLocaleDateString('fr-CA') // AAAA-MM-JJ
: new Date().toLocaleDateString('fr-CA');
const supplierPart = (supplierName || '').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const numberPart = (invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const parts = [datePart, supplierPart, numberPart].filter(Boolean);
const filename = parts.length > 0 ? `${parts.join(' - ')}.pdf` : fallback;
// Passer le filename au serveur pour qu'il soit dans Content-Disposition
const downloadUrl = `/api/download-bap?pdfPath=${encodeURIComponent(pdfUrl)}&filename=${encodeURIComponent(filename)}`;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
import * as XLSX from 'xlsx';
import { toast } from "sonner";
import { useLocation } from "wouter";
// Helper function to determine field color based on origin
const getFieldColor = (invoice: any, fieldName: string): string => {
try {
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
if (autoFilledFields.includes(fieldName)) {
return "text-green-600 font-semibold"; // Auto-filled by rules
}
// If field has a value but is not auto-filled, it's manual
if (invoice[fieldName]) {
return "text-blue-600 font-semibold"; // Manually filled
}
} catch (error) {
console.error("Error parsing autoFilledFields:", error);
}
return ""; // No color for empty fields
};
export default function InvoicesBAP() {
const [, setLocation] = useLocation();
const [searchQuery, setSearchQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [addDialogType, setAddDialogType] = useState<"service" | "ventilation" | null>(null);
const [newItemName, setNewItemName] = useState("");
const [pendingInvoiceId, setPendingInvoiceId] = useState<number | null>(null);
// Map invoiceId -> pdfUrl après validation BAP (pour bouton téléchargement)
const [bapPdfUrls, setBapPdfUrls] = useState<Record<number, string>>({});
const [textDialogOpen, setTextDialogOpen] = useState(false);
const [selectedInvoiceText, setSelectedInvoiceText] = useState<string | null>(null);
const [isZipDownloading, setIsZipDownloading] = useState(false);
// Accordéon : lignes dépliées
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [allExpanded, setAllExpanded] = useState(false);
// Filtre par période
const currentYear = new Date().getFullYear();
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState<string>("all"); // "all" ou "01".."12"
// Tri
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">("createdAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const toggleRow = (id: number) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAllRows = () => {
if (allExpanded) {
setExpandedIds(new Set());
setAllExpanded(false);
} else {
setExpandedIds(new Set((filteredInvoices || []).map(inv => inv.id)));
setAllExpanded(true);
}
};
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
() => (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[invoices?.map(i => i.id).join(',')]
);
const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery(
{ invoiceIds: validatedInvoiceIds },
{ enabled: validatedInvoiceIds.length > 0 }
);
// Fusionner les pdfUrl persistés (depuis bapHistory) avec ceux en mémoire (validation en cours)
const allBapPdfUrls = { ...(persistedBapPdfUrls || {}), ...bapPdfUrls };
const { data: departments } = trpc.departments.getByUser.useQuery();
const { data: allocations } = trpc.accountingAllocations.getByUser.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.copiedCount} facture(s) exportée(s) avec succès vers:\n${data.exportFolder}`,
{ duration: 5000 }
);
setSelectedIds([]);
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'export");
},
});
const deleteMutation = trpc.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Facture supprimée avec succès");
utils.invoices.list.invalidate();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la suppression");
},
});
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
const reprocessMutation = trpc.invoices.reprocessSelected.useMutation({
onSuccess: (data) => {
if (data.errors > 0) {
toast.warning(`${data.processed} facture(s) retraitée(s), ${data.errors} erreur(s).`, { duration: 5000 });
} else {
toast.success(`${data.processed} facture(s) retraitée(s) avec succès !`, { duration: 4000 });
}
setSelectedIds([]);
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors du retraitement");
},
});
const validateBAPBulkMutation = trpc.invoices.validateBAPBulk.useMutation({
onSuccess: (data) => {
if (data.processed === 0) {
toast.info("Aucune facture éligible à valider en BAP.");
} else {
toast.success(`${data.processed} facture(s) validée(s) BAP avec succès !`, { duration: 4000 });
const pdfUrls = (data.results as any[]).filter(r => r.pdfUrl).map(r => r.pdfUrl as string);
if (pdfUrls.length > 0) {
pdfUrls.forEach(url => window.open(url, '_blank'));
}
}
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la validation BAP en masse");
},
});
const validateBAPMutation = trpc.invoices.validateBAP.useMutation({
onSuccess: (data) => {
// Stocker le pdfUrl pour le bouton téléchargement
if (data.invoiceId && data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [data.invoiceId as number]: data.pdfUrl as string }));
}
// Afficher l'erreur SharePoint si présente
if ((data as any).sharepointUploadStatus === 'error') {
toast.error(`Erreur export SharePoint : ${(data as any).sharepointUploadError || 'Erreur inconnue'}`, { duration: 8000 });
} else if (data.exportMode === 'browser' && data.pdfUrl) {
toast.success("Facture validée BAP ! Ouverture du PDF annoté...", { duration: 3000 });
window.open(data.pdfUrl, '_blank');
} else if ((data as any).sharepointUploadStatus === 'success' && (data as any).sharepointUploadPath) {
toast.success(`Facture validée BAP ! PDF déposé dans SharePoint`, { duration: 6000 });
} else if (data.exportMode === 'folder' && data.exportPath) {
toast.success(`Facture validée BAP ! PDF enregistré dans : ${data.exportPath}`, { duration: 6000 });
} else {
toast.success("Facture validée BAP avec succès !");
}
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la validation BAP");
},
});
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
setSelectedIds([]);
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la dévalidation BAP");
},
});
const updateFieldMutation = trpc.invoices.update.useMutation({
onSuccess: () => {
toast.success("Facture mise à jour");
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la mise à jour");
},
});
const createDepartmentMutation = trpc.departments.create.useMutation({
onSuccess: (newDept) => {
toast.success("Service ajouté avec succès");
utils.departments.getByUser.invalidate();
// Update the invoice with the new department
if (pendingInvoiceId) {
updateFieldMutation.mutate({
id: pendingInvoiceId,
data: { serviceConcerne: newDept.name },
});
}
setAddDialogOpen(false);
setNewItemName("");
setPendingInvoiceId(null);
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'ajout");
},
});
const createAllocationMutation = trpc.accountingAllocations.create.useMutation({
onSuccess: (newAlloc) => {
toast.success("Ventilation ajoutée avec succès");
utils.accountingAllocations.getByUser.invalidate();
// Update the invoice with the new allocation
if (pendingInvoiceId) {
updateFieldMutation.mutate({
id: pendingInvoiceId,
data: { ventilationComptable: newAlloc.name },
});
}
setAddDialogOpen(false);
setNewItemName("");
setPendingInvoiceId(null);
},
onError: (error) => {
toast.error(error.message || "Erreur lors de l'ajout");
},
});
const handleAddNewItem = () => {
if (!newItemName.trim()) {
toast.error("Le nom ne peut pas être vide");
return;
}
if (addDialogType === "service") {
createDepartmentMutation.mutate({ name: newItemName.trim() });
} else if (addDialogType === "ventilation") {
createAllocationMutation.mutate({ name: newItemName.trim() });
}
};
// Déclarée ici (avant filteredInvoices et statusCounts) pour éviter le hoisting error
const isEligibleForBAPValidation = (invoice: any) => {
return (
(invoice.qualityScore || 0) === 100 &&
invoice.exportStatus !== "exported" &&
invoice.isSubscription === 0 &&
invoice.serviceConcerne &&
invoice.typeAchat &&
invoice.ventilationComptable
);
};
// "À compléter" = BAP non validé ET au moins un champ obligatoire manquant
const isToComplete = (invoice: any) => {
return (
invoice.bapValidated !== 1 &&
(!invoice.serviceConcerne || !invoice.typeAchat || !invoice.ventilationComptable)
);
};
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 period (year + optional month)
if (selectedYear !== "all") {
const invDate = inv.invoiceDate ? new Date(inv.invoiceDate) : null;
const fallbackDate = inv.createdAt ? new Date(inv.createdAt) : null;
const dateToUse = invDate || fallbackDate;
if (!dateToUse) return false;
if (dateToUse.getFullYear() !== parseInt(selectedYear)) return false;
if (selectedMonth !== "all" && (dateToUse.getMonth() + 1) !== parseInt(selectedMonth)) 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;
if (statusFilter === "bap_validated" && inv.bapValidated !== 1) return false;
if (statusFilter === "bap_pending" && inv.bapValidated === 1) return false;
if (statusFilter === "to_complete" && !isToComplete(inv)) return false;
}
return true;
});
// Factures filtrées par période uniquement (sans filtre de statut) pour les compteurs
const invoicesInPeriod = invoices?.filter((inv) => {
if (selectedYear === "all") return true;
const invDate = inv.invoiceDate ? new Date(inv.invoiceDate) : null;
const fallbackDate = inv.createdAt ? new Date(inv.createdAt) : null;
const dateToUse = invDate || fallbackDate;
if (!dateToUse) return false;
if (dateToUse.getFullYear() !== parseInt(selectedYear)) return false;
if (selectedMonth !== "all" && (dateToUse.getMonth() + 1) !== parseInt(selectedMonth)) return false;
return true;
}) || [];
const statusCounts = {
all: invoicesInPeriod.length,
exported: invoicesInPeriod.filter(inv => inv.exportStatus === "exported").length,
not_exported: invoicesInPeriod.filter(inv => inv.exportStatus === "not_exported").length,
export_error: invoicesInPeriod.filter(inv => inv.exportStatus === "export_error").length,
bap_validated: invoicesInPeriod.filter(inv => inv.bapValidated === 1).length,
bap_pending: invoicesInPeriod.filter(inv => inv.bapValidated !== 1).length,
to_complete: invoicesInPeriod.filter(inv => isToComplete(inv)).length,
};
// Old filter logic (to be removed)
const _oldFilteredInvoices = invoices?.filter((inv) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
inv.supplierName?.toLowerCase().includes(query) ||
inv.invoiceNumber?.toLowerCase().includes(query)
);
});
const handleSelectAll = (checked: boolean) => {
if (checked) {
// Select ALL invoices (no restriction)
const allIds = (filteredInvoices || []).map(inv => inv.id);
setSelectedIds(allIds);
} else {
setSelectedIds([]);
}
};
const handleSelectOne = (id: number, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id]);
} else {
setSelectedIds(selectedIds.filter(selectedId => selectedId !== id));
}
};
const handleExport = () => {
if (selectedIds.length === 0) {
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":
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>;
};
const isEligibleForExport = (invoice: any) => {
// Une facture BAP est exportable si :
// 1. Score = 100%
// 2. Type d'achat rempli
// 3. Service concerné rempli
// 4. Ventilation comptable remplie
return (
(invoice.qualityScore || 0) === 100 &&
invoice.typeAchat &&
invoice.serviceConcerne &&
invoice.ventilationComptable
);
};
// isEligibleForBAPValidation est déclarée plus haut (avant filteredInvoices)
const getBAPValidationTooltip = (invoice: any): string => {
const reasons: string[] = [];
if ((invoice.qualityScore || 0) < 100) reasons.push("Score < 100%");
if (invoice.exportStatus === "exported") reasons.push("Déjà exportée");
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
if (!invoice.serviceConcerne) reasons.push("Service manquant");
if (!invoice.typeAchat) reasons.push("Type d'achat manquant");
if (!invoice.ventilationComptable) reasons.push("Ventilation manquante");
if (reasons.length === 0) return "Valider cette facture BAP";
return `Non éligible : ${reasons.join(", ")}`;
};
const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => {
const invoice = invoices?.find(inv => inv.id === id);
return invoice && isEligibleForExport(invoice);
});
const handleSort = (field: "invoiceDate" | "createdAt") => {
if (sortField === field) {
setSortDir(d => d === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDir("desc");
}
};
const SortIcon = ({ field }: { field: "invoiceDate" | "createdAt" }) => {
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 ml-1 opacity-40" />;
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 ml-1" /> : <ArrowDown className="w-3 h-3 ml-1" />;
};
const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => {
let aVal: number, bVal: number;
if (sortField === "invoiceDate") {
aVal = a.invoiceDate ? new Date(a.invoiceDate).getTime() : 0;
bVal = b.invoiceDate ? new Date(b.invoiceDate).getTime() : 0;
} else {
aVal = a.createdAt ? new Date(a.createdAt as unknown as string).getTime() : 0;
bVal = b.createdAt ? new Date(b.createdAt as unknown as string).getTime() : 0;
}
return sortDir === "asc" ? aVal - bVal : bVal - aVal;
});
const allEligibleSelected = (sortedInvoices.length || 0) > 0 &&
sortedInvoices.every(inv => selectedIds.includes(inv.id));
return (
<DashboardLayout>
<div className="space-y-4">
{/* En-tête : titre + sous-titre */}
<div>
<h1 className="text-3xl font-bold">Factures BAP</h1>
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
</div>
{/* Barre d'actions : 2 lignes */}
<div className="space-y-2">
{/* Ligne 1 : Excel, ZIP, Valider tout en BAP, Importer */}
<div className="flex flex-wrap 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={async () => {
const files = selectedIds
.map(id => {
const inv = invoices?.find(i => i.id === id);
const pdfUrl = allBapPdfUrls[id];
if (!inv || !pdfUrl) return null;
const datePart = new Date().toLocaleDateString('fr-CA');
const supplierPart = (inv.supplierName || '').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const numberPart = (inv.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const parts = [datePart, supplierPart, numberPart].filter(Boolean);
const filename = parts.length > 0 ? `${parts.join(' - ')}.pdf` : pdfUrl.split('/').pop() || 'BAP.pdf';
return { pdfPath: pdfUrl, filename };
})
.filter(Boolean) as Array<{ pdfPath: string; filename: string }>;
if (!files.length) {
toast.error('Aucune facture validée BAP parmi la sélection');
return;
}
setIsZipDownloading(true);
try {
await downloadBapZip(files);
toast.success(`ZIP généré avec ${files.length} PDF(s)`);
} catch (err: any) {
toast.error(err.message || 'Erreur lors de la génération du ZIP');
} finally {
setIsZipDownloading(false);
}
}}
disabled={selectedIds.length === 0 || isZipDownloading}
variant="outline"
className="border-indigo-500 text-indigo-600 hover:bg-indigo-50"
>
<FolderDown className={`w-4 h-4 mr-2 ${isZipDownloading ? 'animate-bounce' : ''}`} />
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
</Button>
<Button
onClick={() => {
if (confirm("Valider en BAP toutes les factures éligibles (score 100%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.")) {
validateBAPBulkMutation.mutate();
}
}}
disabled={validateBAPBulkMutation.isPending}
className="bg-green-700 hover:bg-green-800 text-white"
>
<ShieldCheck className="w-4 h-4 mr-2" />
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
</Button>
<Button onClick={() => setLocation("/upload")}>
<FileText className="w-4 h-4 mr-2" />
Importer
</Button>
</div>
{/* Ligne 2 : Supprimer, Dévalider BAP, Relancer */}
<div className="flex flex-wrap gap-2">
<Button
onClick={async () => {
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
try {
for (const id of selectedIds) {
await utils.client.invoices.delete.mutate({ id });
}
toast.success(`${selectedIds.length} facture(s) supprimée(s)`);
setSelectedIds([]);
utils.invoices.list.invalidate();
} catch (error: any) {
toast.error(error.message || "Erreur lors de la suppression");
}
}
}}
disabled={selectedIds.length === 0}
variant="destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer ({selectedIds.length})
</Button>
<Button
onClick={() => {
const bapIds = selectedIds.filter(id => invoices?.find(inv => inv.id === id)?.bapValidated === 1);
if (bapIds.length === 0) {
toast.error("Aucune facture BAP validée parmi la sélection");
return;
}
if (confirm(`Dévalider ${bapIds.length} facture(s) BAP ? Elles repasser ont en statut à traiter.`)) {
devalidateBAPMutation.mutate({ invoiceIds: bapIds });
}
}}
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
>
<ShieldCheck className="w-4 h-4 mr-2 rotate-180" />
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
</Button>
<Button
onClick={() => {
if (selectedIds.length === 0) {
toast.error("Veuillez sélectionner au moins une facture");
return;
}
if (confirm(`Relancer l'analyse IA et les automatismes sur ${selectedIds.length} facture(s) sélectionnée(s) ?\nCela peut prendre quelques secondes par facture.`)) {
reprocessMutation.mutate({ invoiceIds: selectedIds });
}
}}
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
variant="outline"
className="border-purple-500 text-purple-600 hover:bg-purple-50"
>
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
</Button>
</div>
</div>
{/* ===== CARTOUCHE FILTRES BAP ===== */}
<div className="rounded-xl border border-emerald-100 bg-emerald-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Ligne 1 : Recherche */}
<div className="flex gap-3 items-center flex-wrap mb-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-emerald-200 focus:border-emerald-400"
/>
</div>
</div>
{/* Ligne 2 : Filtres période (année + mois) */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={selectedYear === "all"}
>
<SelectTrigger className="w-36 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{[
["01","Janvier"],["02","Février"],["03","Mars"],["04","Avril"],
["05","Mai"],["06","Juin"],["07","Juillet"],["08","Août"],
["09","Septembre"],["10","Octobre"],["11","Novembre"],["12","Décembre"]
].map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
className="text-xs text-emerald-600 hover:text-emerald-800 underline"
>
Réinitialiser
</button>
)}
</div>
{/* Ligne 3 : Boutons statut + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
className={statusFilter === "all" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
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 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
>
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 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
>
Erreurs ({statusCounts.export_error})
</Button>
<Button
variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")}
size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Validées BAP ({statusCounts.bap_validated})
</Button>
<Button
variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")}
size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700 text-white" : "bg-white border-orange-200 text-orange-700 hover:bg-orange-50"}
>
En attente BAP ({statusCounts.bap_pending})
</Button>
<Button
variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")}
size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-white border-amber-200 text-amber-700 hover:bg-amber-50"}
>
À compléter ({statusCounts.to_complete})
</Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-emerald-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
</div>
{/* ===== FIN CARTOUCHE BAP ===== */}
<Card>
<CardContent className="pt-4">
{/* Table */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div>
) : sortedInvoices.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">
{/* Bouton global +/- */}
<button
onClick={toggleAllRows}
className="flex items-center justify-center w-6 h-6 rounded border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-600"
title={allExpanded ? "Replier toutes les lignes" : "Déplier toutes les lignes"}
>
<ChevronsUpDown className="w-3 h-3" />
</button>
</TableHead>
<TableHead className="w-10">
<Checkbox
checked={allEligibleSelected}
onCheckedChange={handleSelectAll}
title="Sélectionner toutes les factures"
/>
</TableHead>
<TableHead>Fournisseur</TableHead>
<TableHead>N° Facture</TableHead>
<TableHead
className="cursor-pointer select-none"
onClick={() => handleSort("invoiceDate")}
>
<span className="flex items-center">Date facture <SortIcon field="invoiceDate" /></span>
</TableHead>
<TableHead
className="cursor-pointer select-none"
onClick={() => handleSort("createdAt")}
>
<span className="flex items-center">Date réception <SortIcon field="createdAt" /></span>
</TableHead>
<TableHead>Montant</TableHead>
<TableHead>Score</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="w-36">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedInvoices.map((invoice) => {
const isExpanded = expandedIds.has(invoice.id);
return (
<React.Fragment key={invoice.id}>
<TableRow className="hover:bg-gray-50">
{/* Bouton +/- individuel */}
<TableCell className="p-1">
<button
onClick={() => toggleRow(invoice.id)}
className="flex items-center justify-center w-6 h-6 rounded border border-gray-300 bg-white hover:bg-gray-100 text-gray-500"
title={isExpanded ? "Replier" : "Déplier"}
>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
</TableCell>
<TableCell>
<Checkbox
checked={selectedIds.includes(invoice.id)}
onCheckedChange={(checked) => handleSelectOne(invoice.id, checked as boolean)}
/>
</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>
{invoice.invoiceDate
? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR")
: "-"}
</TableCell>
<TableCell className="text-xs text-gray-500">
{invoice.createdAt
? new Date(invoice.createdAt as unknown as string).toLocaleDateString("fr-FR")
: "-"}
</TableCell>
<TableCell>
{invoice.totalAmount
? `${parseFloat(invoice.totalAmount as string).toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getQualityBadge(invoice.qualityScore)}
{isEligibleForExport(invoice) && (
<div title="Facture exportable">
<CheckCircle className="h-4 w-4 text-green-600" />
</div>
)}
</div>
</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedInvoiceText(invoice.extractedText || "Aucun texte extrait disponible");
setTextDialogOpen(true);
}}
className="h-8 px-2"
title="Voir le texte extrait"
>
<FileText className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setLocation(`/invoices/${invoice.id}`)}
className="h-8 px-2"
title="Modifier"
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setInvoiceToDelete(invoice.id);
setDeleteDialogOpen(true);
}}
className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Supprimer"
>
<Trash className="h-4 w-4" />
</Button>
{invoice.bapValidated === 1 ? (
<div className="flex gap-1 items-center">
<div
className="h-8 px-2 flex items-center justify-center rounded border border-green-300 bg-green-50 text-green-700 text-xs font-semibold gap-1"
title={`Validé BAP le ${invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt).toLocaleDateString('fr-FR') : ''}`}
>
<CheckCircle2 className="h-4 w-4" />
<span>Validé</span>
</div>
{allBapPdfUrls[invoice.id] ? (
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-blue-600 border-blue-300 hover:bg-blue-50"
title="Télécharger le PDF annoté BAP"
onClick={() => downloadBapPdf(allBapPdfUrls[invoice.id], invoice.supplierName || undefined, invoice.invoiceNumber, invoice.bapValidatedAt)}
>
<Download className="h-4 w-4" />
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible — cliquer pour régénérer"
disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
>
{regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
onClick={() => validateBAPMutation.mutate({ id: invoice.id })}
disabled={!isEligibleForBAPValidation(invoice) || validateBAPMutation.isPending}
className={`h-8 px-2 gap-1 ${
isEligibleForBAPValidation(invoice)
? "text-emerald-700 border-emerald-400 hover:bg-emerald-50 hover:border-emerald-500"
: "text-gray-400 border-gray-200 cursor-not-allowed opacity-50"
}`}
title={getBAPValidationTooltip(invoice)}
>
<CheckCircle2 className="h-4 w-4" />
<span className="text-xs">BAP</span>
</Button>
)}
</div>
</TableCell>
</TableRow>
{/* Ligne 2 : détails dépliables (Service, Type achat, Ventilation) */}
{isExpanded && (
<TableRow key={`${invoice.id}-details`} className="bg-blue-50/40 border-t-0">
{/* col +/- vide */}
<TableCell className="p-1" />
{/* col checkbox vide */}
<TableCell />
{/* colspan 6 pour les 3 champs */}
<TableCell colSpan={6} className="py-2 px-4">
<div className="flex flex-wrap gap-4 items-center">
{/* Service */}
<div className="flex items-center gap-2 min-w-[200px]">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Service :</span>
<select
value={invoice.serviceConcerne || ""}
onChange={(e) => {
if (e.target.value === "__ADD_NEW__") {
setPendingInvoiceId(invoice.id);
setAddDialogType("service");
setAddDialogOpen(true);
e.target.value = invoice.serviceConcerne || "";
} else {
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "serviceConcerne");
const newVal = e.target.value || undefined;
if (invoice.supplierName && newVal !== (invoice.serviceConcerne || undefined)) {
upsertLearningMutation.mutate({ supplierName: invoice.supplierName, fieldName: 'serviceConcerne', originalValue: invoice.serviceConcerne || undefined, correctedValue: newVal || '' });
}
updateFieldMutation.mutate({ id: invoice.id, data: { serviceConcerne: newVal, autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null } });
}
}}
className={`flex-1 px-2 py-1 border rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${getFieldColor(invoice, "serviceConcerne")}`}
>
<option value="">-</option>
{departments?.map((dept) => (<option key={dept.id} value={dept.name}>{dept.name}</option>))}
<option value="__ADD_NEW__" className="font-semibold text-blue-600"> Ajouter...</option>
</select>
</div>
{/* Type achat */}
<div className="flex items-center gap-2 min-w-[160px]">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Type achat :</span>
<select
value={invoice.typeAchat || ""}
onChange={(e) => {
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "typeAchat");
const newTypeAchat = e.target.value as "CAPEX" | "OPEX" | undefined;
if (invoice.supplierName && newTypeAchat !== (invoice.typeAchat || undefined)) {
upsertLearningMutation.mutate({ supplierName: invoice.supplierName, fieldName: 'typeAchat', originalValue: invoice.typeAchat || undefined, correctedValue: newTypeAchat || '' });
}
updateFieldMutation.mutate({ id: invoice.id, data: { typeAchat: newTypeAchat, autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null } });
}}
className={`flex-1 px-2 py-1 border rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${getFieldColor(invoice, "typeAchat")}`}
>
<option value="">-</option>
<option value="CAPEX">CAPEX</option>
<option value="OPEX">OPEX</option>
</select>
</div>
{/* Ventilation */}
<div className="flex items-center gap-2 min-w-[220px]">
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Ventilation :</span>
<select
value={invoice.ventilationComptable || ""}
onChange={(e) => {
if (e.target.value === "__ADD_NEW__") {
setPendingInvoiceId(invoice.id);
setAddDialogType("ventilation");
setAddDialogOpen(true);
e.target.value = invoice.ventilationComptable || "";
} else {
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "ventilationComptable");
const newVentil = e.target.value || undefined;
if (invoice.supplierName && newVentil !== (invoice.ventilationComptable || undefined)) {
upsertLearningMutation.mutate({ supplierName: invoice.supplierName, fieldName: 'ventilationComptable', originalValue: invoice.ventilationComptable || undefined, correctedValue: newVentil || '' });
}
updateFieldMutation.mutate({ id: invoice.id, data: { ventilationComptable: newVentil, autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null } });
}
}}
className={`flex-1 px-2 py-1 border rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${getFieldColor(invoice, "ventilationComptable")}`}
>
<option value="">-</option>
{allocations?.map((alloc) => (<option key={alloc.id} value={alloc.name}>{alloc.name}</option>))}
<option value="__ADD_NEW__" className="font-semibold text-blue-600"> Ajouter...</option>
</select>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-12">
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-gray-500">Aucune facture trouvée</p>
<p className="text-sm text-gray-400 mt-1">
{searchQuery ? "Essayez une autre recherche" : "Commencez par importer un fichier PDF"}
</p>
</div>
)}
</CardContent>
</Card>
</div>{/* fin space-y-4 */}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr de vouloir supprimer cette facture ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. La facture sera définitivement supprimée de la base de données.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setInvoiceToDelete(null)}>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (invoiceToDelete) {
deleteMutation.mutate({ id: invoiceToDelete });
}
}}
className="bg-red-600 hover:bg-red-700"
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Add New Item Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Ajouter {addDialogType === "service" ? "un nouveau service" : "une nouvelle ventilation"}
</DialogTitle>
<DialogDescription>
Entrez le nom {addDialogType === "service" ? "du service" : "de la ventilation comptable"} à ajouter.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
placeholder="Nom..."
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddNewItem();
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setAddDialogOpen(false);
setNewItemName("");
setPendingInvoiceId(null);
}}
>
Annuler
</Button>
<Button onClick={handleAddNewItem}>Ajouter</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Extracted Text Dialog */}
<Dialog open={textDialogOpen} onOpenChange={setTextDialogOpen}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Texte extrait de la facture</DialogTitle>
<DialogDescription>
Texte complet extrait du PDF lors de l'import
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto max-h-[60vh] p-4 bg-gray-50 rounded border">
<pre className="whitespace-pre-wrap text-sm font-mono">{selectedInvoiceText}</pre>
</div>
<DialogFooter>
<Button onClick={() => setTextDialogOpen(false)}>Fermer</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}