feat: historique imports - filtres, compteurs et bouton visualisation

This commit is contained in:
Manus
2026-05-03 06:41:50 -04:00
parent 8a02125fe2
commit 4dbc161a5f

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -10,7 +11,19 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { History as HistoryIcon, FileText, Trash2 } from "lucide-react"; import {
History as HistoryIcon,
FileText,
Trash2,
Eye,
CheckCircle2,
XCircle,
AlertTriangle,
CalendarDays,
RotateCcw,
Package,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -24,12 +37,53 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors";
const MONTHS = [
{ value: "1", label: "Janvier" },
{ value: "2", label: "Février" },
{ value: "3", label: "Mars" },
{ value: "4", label: "Avril" },
{ value: "5", label: "Mai" },
{ value: "6", label: "Juin" },
{ value: "7", label: "Juillet" },
{ value: "8", label: "Août" },
{ value: "9", label: "Septembre" },
{ value: "10", label: "Octobre" },
{ value: "11", label: "Novembre" },
{ value: "12", label: "Décembre" },
];
const YEARS = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
export default function History() { export default function History() {
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const deleteAllMutation = trpc.importLogs.deleteAll.useMutation(); const deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// Filtres
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
// Dialog visualisation détails
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedLog, setSelectedLog] = useState<(typeof logs extends (infer T)[] | undefined ? T : never) | null>(null);
const handleDeleteAll = async () => { const handleDeleteAll = async () => {
try { try {
await deleteAllMutation.mutateAsync(); await deleteAllMutation.mutateAsync();
@@ -37,22 +91,65 @@ export default function History() {
toast.success("Logs supprimés", { toast.success("Logs supprimés", {
description: "Tous les logs d'import ont été supprimés avec succès.", description: "Tous les logs d'import ont été supprimés avec succès.",
}); });
} catch (error) { } catch {
toast.error("Erreur", { toast.error("Erreur", {
description: "Impossible de supprimer les logs d'import.", description: "Impossible de supprimer les logs d'import.",
}); });
} }
}; };
// Filtrage par période
const periodFiltered = useMemo(() => {
if (!logs) return [];
return logs.filter((log) => {
const d = new Date(log.importedAt);
if (yearFilter !== "all" && d.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && (d.getMonth() + 1) !== Number(monthFilter)) return false;
return true;
});
}, [logs, yearFilter, monthFilter]);
// Compteurs sur la période filtrée
const counts = useMemo(() => {
const totalImported = periodFiltered.reduce((s, l) => s + l.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((s, l) => s + l.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((s, l) => s + l.errors, 0);
const totalDetected = periodFiltered.reduce((s, l) => s + l.totalInvoicesDetected, 0);
const totalNotImported = totalDuplicates + totalErrors;
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported };
}, [periodFiltered]);
// Filtrage par statut
const filteredLogs = useMemo(() => {
return periodFiltered.filter((log) => {
if (statusFilter === "all") return true;
if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "errors") return log.errors > 0;
return true;
});
}, [periodFiltered, statusFilter]);
const hasPeriodFilter = yearFilter !== "all" || monthFilter !== "all";
const filterButtons: { key: StatusFilter; label: string; count: number; color: string; activeColor: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, color: "bg-gray-100 text-gray-700 hover:bg-gray-200", activeColor: "bg-gray-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, color: "bg-green-50 text-green-700 hover:bg-green-100 border border-green-200", activeColor: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, color: "bg-orange-50 text-orange-700 hover:bg-orange-100 border border-orange-200", activeColor: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, color: "bg-yellow-50 text-yellow-700 hover:bg-yellow-100 border border-yellow-200", activeColor: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, color: "bg-red-50 text-red-700 hover:bg-red-100 border border-red-200", activeColor: "bg-red-600 text-white" },
];
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* En-tête */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<h1 className="text-3xl font-bold">Historique des imports</h1> <h1 className="text-3xl font-bold">Historique des imports</h1>
<p className="text-gray-500 mt-1">Consultez l'historique de tous vos imports de factures</p> <p className="text-gray-500 mt-1">Consultez l'historique de tous vos imports de factures</p>
</div> </div>
{logs && logs.length > 0 && ( {logs && logs.length > 0 && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -83,72 +180,309 @@ export default function History() {
)} )}
</div> </div>
{/* Compteurs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="border-green-200 bg-green-50">
<CardContent className="p-4 flex items-center gap-3">
<CheckCircle2 className="h-8 w-8 text-green-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-green-700">{counts.totalImported}</p>
<p className="text-xs text-green-600 font-medium">Factures importées</p>
</div>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4 flex items-center gap-3">
<XCircle className="h-8 w-8 text-orange-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p>
<p className="text-xs text-orange-500 font-medium">Non importées</p>
</div>
</CardContent>
</Card>
<Card className="border-yellow-200 bg-yellow-50">
<CardContent className="p-4 flex items-center gap-3">
<RotateCcw className="h-8 w-8 text-yellow-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p>
<p className="text-xs text-yellow-600 font-medium">Doublons ignorés</p>
</div>
</CardContent>
</Card>
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-red-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p>
<p className="text-xs text-red-500 font-medium">Erreurs</p>
</div>
</CardContent>
</Card>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3 items-center">
{/* Filtres statut */}
<div className="flex flex-wrap gap-2">
{filterButtons.map((btn) => (
<button
key={btn.key}
onClick={() => setStatusFilter(btn.key)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
statusFilter === btn.key ? btn.activeColor : btn.color
}`}
>
{btn.label}
<span className={`ml-1.5 inline-flex items-center justify-center rounded-full text-xs px-1.5 py-0.5 font-bold ${
statusFilter === btn.key ? "bg-white/20" : "bg-white/60"
}`}>
{btn.count}
</span>
</button>
))}
</div>
{/* Séparateur */}
<div className="h-6 w-px bg-gray-200" />
{/* Filtre Période */}
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-gray-500" />
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}>
<SelectTrigger className="h-8 w-28 text-sm">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{YEARS.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={monthFilter}
onValueChange={setMonthFilter}
disabled={yearFilter === "all"}
>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{hasPeriodFilter && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-gray-500 hover:text-gray-700"
onClick={() => { setYearFilter("all"); setMonthFilter("all"); }}
title="Réinitialiser la période"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Résumé */}
<span className="text-sm text-gray-500 ml-auto">
{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}
</span>
</div>
{/* Tableau */}
<Card> <Card>
<CardHeader> <CardContent className="p-0">
<CardTitle>Logs d'import</CardTitle>
<CardDescription>Détails de chaque import avec statistiques</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div> <div className="text-center py-12 text-gray-500">
) : logs && logs.length > 0 ? ( <Package className="w-10 h-10 mx-auto mb-3 text-gray-300 animate-pulse" />
<p>Chargement...</p>
</div>
) : filteredLogs.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="bg-gray-50">
<TableHead>Fichier</TableHead> <TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead>Date</TableHead> <TableHead className="font-semibold">Date d'import</TableHead>
<TableHead>Détectées</TableHead> <TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead>Importées</TableHead> <TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead>Doublons</TableHead> <TableHead className="font-semibold text-center">Doublons</TableHead>
<TableHead>Erreurs</TableHead> <TableHead className="font-semibold text-center">Erreurs</TableHead>
<TableHead className="font-semibold text-center w-20">Détails</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{logs.map((log) => ( {filteredLogs.map((log) => (
<TableRow key={log.id}> <TableRow key={log.id} className="hover:bg-gray-50/50 transition-colors">
<TableCell className="font-medium">{log.fileName}</TableCell> <TableCell className="font-medium max-w-xs truncate" title={log.fileName}>
<TableCell> <div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<span className="truncate">{log.fileName}</span>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")} {new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center">
<Badge variant="outline">{log.totalInvoicesDetected}</Badge> <Badge variant="outline" className="font-mono">
</TableCell> {log.totalInvoicesDetected}
<TableCell>
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
{log.invoicesImported}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center">
{log.invoicesImported > 0 ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100 font-mono">
{log.invoicesImported}
</Badge>
) : (
<span className="text-gray-400 text-sm">0</span>
)}
</TableCell>
<TableCell className="text-center">
{log.duplicatesIgnored > 0 ? ( {log.duplicatesIgnored > 0 ? (
<Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100"> <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100 font-mono">
{log.duplicatesIgnored} {log.duplicatesIgnored}
</Badge> </Badge>
) : ( ) : (
<span className="text-gray-400">-</span> <span className="text-gray-400">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-center">
{log.errors > 0 ? ( {log.errors > 0 ? (
<Badge className="bg-red-100 text-red-800 hover:bg-red-100"> <Badge className="bg-red-100 text-red-800 hover:bg-red-100 font-mono">
{log.errors} {log.errors}
</Badge> </Badge>
) : ( ) : (
<span className="text-gray-400">-</span> <span className="text-gray-400">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-blue-600 border-blue-200 hover:bg-blue-50 hover:border-blue-400"
title="Voir les détails de cet import"
onClick={() => {
setSelectedLog(log);
setDetailDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
) : ( ) : (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-12 text-gray-500">
<HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" /> <HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>Aucun historique d'import</p> <p className="font-medium">Aucun import trouvé</p>
<p className="text-sm mt-1">Modifiez les filtres pour afficher plus de résultats</p>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Dialog visualisation détails */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
Détails de l'import
</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
{/* Infos générales */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Fichier</p>
<p className="text-sm font-semibold break-all">{selectedLog.fileName}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div>
</div>
{/* Statistiques */}
<div className="grid grid-cols-4 gap-2">
<div className="text-center bg-gray-50 rounded-lg p-3">
<p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p>
<p className="text-xs text-gray-500 mt-1">Détectées</p>
</div>
<div className="text-center bg-green-50 rounded-lg p-3">
<p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p>
<p className="text-xs text-green-600 mt-1">Importées</p>
</div>
<div className="text-center bg-yellow-50 rounded-lg p-3">
<p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p>
<p className="text-xs text-yellow-600 mt-1">Doublons</p>
</div>
<div className="text-center bg-red-50 rounded-lg p-3">
<p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p>
<p className="text-xs text-red-500 mt-1">Erreurs</p>
</div>
</div>
{/* Détails doublons */}
{selectedLog.duplicateDetails && (() => {
try {
const details = JSON.parse(selectedLog.duplicateDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-yellow-700 mb-2 flex items-center gap-1">
<RotateCcw className="h-4 w-4" />
Doublons ignorés ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { supplier?: string; invoiceNumber?: string; reason?: string }, i: number) => (
<div key={i} className="text-xs bg-yellow-50 border border-yellow-100 rounded px-3 py-1.5 text-yellow-800">
{typeof d === "string" ? d : `${d.supplier || ""}${d.invoiceNumber || ""} ${d.reason ? `(${d.reason})` : ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
{/* Détails erreurs */}
{selectedLog.errorDetails && (() => {
try {
const details = JSON.parse(selectedLog.errorDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-1">
<AlertTriangle className="h-4 w-4" />
Erreurs ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { message?: string; file?: string }, i: number) => (
<div key={i} className="text-xs bg-red-50 border border-red-100 rounded px-3 py-1.5 text-red-700">
{typeof d === "string" ? d : `${d.file ? `[${d.file}] ` : ""}${d.message || ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
</div>
)}
</DialogContent>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }