feat: historique imports - filtres, compteurs et bouton visualisation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -10,7 +11,19 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { toast } from "sonner";
|
||||
import {
|
||||
@@ -24,12 +37,53 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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() {
|
||||
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
|
||||
const deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
|
||||
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 () => {
|
||||
try {
|
||||
await deleteAllMutation.mutateAsync();
|
||||
@@ -37,22 +91,65 @@ export default function History() {
|
||||
toast.success("Logs supprimés", {
|
||||
description: "Tous les logs d'import ont été supprimés avec succès.",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Erreur", {
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{logs && logs.length > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -83,72 +180,309 @@ export default function History() {
|
||||
)}
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Logs d'import</CardTitle>
|
||||
<CardDescription>Détails de chaque import avec statistiques</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||
) : logs && logs.length > 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Package className="w-10 h-10 mx-auto mb-3 text-gray-300 animate-pulse" />
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
) : filteredLogs.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fichier</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Détectées</TableHead>
|
||||
<TableHead>Importées</TableHead>
|
||||
<TableHead>Doublons</TableHead>
|
||||
<TableHead>Erreurs</TableHead>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">Fichier importé</TableHead>
|
||||
<TableHead className="font-semibold">Date d'import</TableHead>
|
||||
<TableHead className="font-semibold text-center">Détectées</TableHead>
|
||||
<TableHead className="font-semibold text-center">Importées</TableHead>
|
||||
<TableHead className="font-semibold text-center">Doublons</TableHead>
|
||||
<TableHead className="font-semibold text-center">Erreurs</TableHead>
|
||||
<TableHead className="font-semibold text-center w-20">Détails</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">{log.fileName}</TableCell>
|
||||
<TableCell>
|
||||
{filteredLogs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<TableCell className="font-medium max-w-xs truncate" title={log.fileName}>
|
||||
<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")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.totalInvoicesDetected}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">
|
||||
{log.invoicesImported}
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{log.totalInvoicesDetected}
|
||||
</Badge>
|
||||
</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 ? (
|
||||
<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}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
{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}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
</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" />
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user