feat: page rapport détaillé des imports avec filtres, compteurs et détails doublons/erreurs

This commit is contained in:
Manus
2026-05-03 10:23:40 -04:00
parent 4dbc161a5f
commit 973cc67603
3 changed files with 521 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import Users from "./pages/Users";
import ListsAdmin from "./pages/ListsAdmin";
import AutomationRules from "./pages/AutomationRules";
import BapHistory from "./pages/BapHistory";
import ImportReport from "./pages/ImportReport";
import LearningSettings from "./pages/LearningSettings";
function Router() {
@@ -37,6 +38,7 @@ function Router() {
<Route path="/lists-admin" component={ListsAdmin} />
<Route path="/automation-rules" component={AutomationRules} />
<Route path="/bap-history" component={BapHistory} />
<Route path="/import-report" component={ImportReport} />
<Route path="/learning-settings" component={LearningSettings} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -25,7 +25,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2 } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -76,6 +76,7 @@ const menuStructure: MenuItem[] = [
color: "from-green-500 to-emerald-500",
children: [
{ icon: History, label: "Historiques", path: "/history" },
{ icon: BarChart2, label: "Rapport imports", path: "/import-report" },
{ icon: CheckSquare, label: "Historique BAP", path: "/bap-history" },
],
},

View File

@@ -0,0 +1,517 @@
import { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { trpc } from "@/lib/trpc";
import {
CheckCircle2, XCircle, Copy, AlertTriangle, FileText,
CalendarDays, TrendingUp, Download, Eye,
} from "lucide-react";
type ImportLog = {
id: number;
fileName: string;
totalInvoicesDetected: number;
invoicesImported: number;
duplicatesIgnored: number;
errors: number;
duplicateDetails: string | null;
errorDetails: string | null;
importedAt: Date | string;
};
type DuplicateDetail = {
supplierName?: string;
invoiceNumber?: string;
invoiceDate?: string;
};
type ErrorDetail = {
message?: string;
field?: string;
value?: string;
};
function parseJson<T>(str: string | null | undefined): T[] {
if (!str) return [];
try {
const parsed = JSON.parse(str);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function StatusBadge({ imported, duplicates, errors }: { imported: number; duplicates: number; errors: number }) {
if (errors > 0) return <Badge className="bg-red-100 text-red-700 border-red-200">Erreurs ({errors})</Badge>;
if (duplicates > 0) return <Badge className="bg-amber-100 text-amber-700 border-amber-200">Doublons ({duplicates})</Badge>;
if (imported > 0) return <Badge className="bg-green-100 text-green-700 border-green-200">Importée</Badge>;
return <Badge className="bg-gray-100 text-gray-600 border-gray-200">Ignorée</Badge>;
}
export default function ImportReport() {
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [selectedLog, setSelectedLog] = useState<ImportLog | null>(null);
// Années disponibles
const availableYears = useMemo(() => {
if (!logs) return [String(new Date().getFullYear())];
const years = Array.from(new Set(logs.map(l => new Date(l.importedAt).getFullYear())));
return years.sort((a, b) => b - a).map(String);
}, [logs]);
// Filtrage
const filteredLogs = useMemo(() => {
if (!logs) return [];
return logs.filter((log) => {
const d = new Date(log.importedAt);
if (yearFilter !== "all" && d.getFullYear() !== parseInt(yearFilter)) return false;
if (monthFilter !== "all" && d.getMonth() + 1 !== parseInt(monthFilter)) return false;
if (statusFilter === "imported" && log.invoicesImported === 0) return false;
if (statusFilter === "not_imported" && (log.duplicatesIgnored === 0 && log.errors === 0)) return false;
if (statusFilter === "duplicates" && log.duplicatesIgnored === 0) return false;
if (statusFilter === "errors" && log.errors === 0) return false;
return true;
});
}, [logs, yearFilter, monthFilter, statusFilter]);
// Compteurs globaux (sur les logs filtrés par période uniquement)
const periodLogs = useMemo(() => {
if (!logs) return [];
return logs.filter((log) => {
const d = new Date(log.importedAt);
if (yearFilter !== "all" && d.getFullYear() !== parseInt(yearFilter)) return false;
if (monthFilter !== "all" && d.getMonth() + 1 !== parseInt(monthFilter)) return false;
return true;
});
}, [logs, yearFilter, monthFilter]);
const totals = useMemo(() => ({
detected: periodLogs.reduce((s, l) => s + l.totalInvoicesDetected, 0),
imported: periodLogs.reduce((s, l) => s + l.invoicesImported, 0),
duplicates: periodLogs.reduce((s, l) => s + l.duplicatesIgnored, 0),
errors: periodLogs.reduce((s, l) => s + l.errors, 0),
files: periodLogs.length,
}), [periodLogs]);
const statusCounts = useMemo(() => ({
all: periodLogs.length,
imported: periodLogs.filter(l => l.invoicesImported > 0 && l.duplicatesIgnored === 0 && l.errors === 0).length,
not_imported: periodLogs.filter(l => l.invoicesImported === 0).length,
duplicates: periodLogs.filter(l => l.duplicatesIgnored > 0).length,
errors: periodLogs.filter(l => l.errors > 0).length,
}), [periodLogs]);
const importRate = totals.detected > 0
? Math.round((totals.imported / totals.detected) * 100)
: 0;
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" },
];
// Export CSV
const exportCsv = () => {
const headers = ["Fichier", "Date import", "Détectées", "Importées", "Doublons", "Erreurs", "Statut"];
const rows = filteredLogs.map(l => [
l.fileName,
new Date(l.importedAt).toLocaleDateString("fr-FR"),
l.totalInvoicesDetected,
l.invoicesImported,
l.duplicatesIgnored,
l.errors,
l.errors > 0 ? "Erreur" : l.duplicatesIgnored > 0 ? "Doublon" : l.invoicesImported > 0 ? "Importée" : "Ignorée",
]);
const csv = [headers, ...rows].map(r => r.join(";")).join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rapport_imports_${new Date().toLocaleDateString("fr-CA")}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<DashboardLayout>
<div className="p-6 space-y-6">
{/* En-tête */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rapport des imports</h1>
<p className="text-sm text-gray-500 mt-1">Analyse détaillée des factures importées et non importées</p>
</div>
<Button variant="outline" onClick={exportCsv} className="flex items-center gap-2">
<Download className="w-4 h-4" />
Exporter CSV
</Button>
</div>
{/* Compteurs synthèse */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card className="border-0 shadow-sm bg-blue-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Fichiers</span>
</div>
<p className="text-2xl font-bold text-blue-800">{totals.files}</p>
<p className="text-xs text-blue-600">imports traités</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-purple-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span className="text-xs font-medium text-purple-600 uppercase tracking-wide">Détectées</span>
</div>
<p className="text-2xl font-bold text-purple-800">{totals.detected}</p>
<p className="text-xs text-purple-600">factures trouvées</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-green-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-green-600 uppercase tracking-wide">Importées</span>
</div>
<p className="text-2xl font-bold text-green-800">{totals.imported}</p>
<p className="text-xs text-green-600">taux : {importRate}%</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-amber-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<Copy className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-amber-600 uppercase tracking-wide">Doublons</span>
</div>
<p className="text-2xl font-bold text-amber-800">{totals.duplicates}</p>
<p className="text-xs text-amber-600">ignorés</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-red-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-red-600" />
<span className="text-xs font-medium text-red-600 uppercase tracking-wide">Erreurs</span>
</div>
<p className="text-2xl font-bold text-red-800">{totals.errors}</p>
<p className="text-xs text-red-600">échecs extraction</p>
</CardContent>
</Card>
</div>
{/* Barre de progression globale */}
{totals.detected > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Taux d'import global</span>
<span className="text-sm font-bold text-gray-900">{importRate}% ({totals.imported}/{totals.detected})</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 flex overflow-hidden">
<div
className="bg-green-500 h-3 transition-all"
style={{ width: `${(totals.imported / totals.detected) * 100}%` }}
/>
<div
className="bg-amber-400 h-3 transition-all"
style={{ width: `${(totals.duplicates / totals.detected) * 100}%` }}
/>
<div
className="bg-red-400 h-3 transition-all"
style={{ width: `${(totals.errors / totals.detected) * 100}%` }}
/>
</div>
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500 inline-block" /> Importées</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400 inline-block" /> Doublons</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-400 inline-block" /> Erreurs</span>
</div>
</CardContent>
</Card>
)}
{/* Filtres */}
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<div className="flex flex-wrap gap-3 items-center">
{/* Filtres statut */}
<div className="flex flex-wrap gap-2">
{[
{ key: "all", label: `Tous (${statusCounts.all})`, color: "default" },
{ key: "imported", label: `Importées (${statusCounts.imported})`, color: "green" },
{ key: "not_imported", label: `Non importées (${statusCounts.not_imported})`, color: "gray" },
{ key: "duplicates", label: `Doublons (${statusCounts.duplicates})`, color: "amber" },
{ key: "errors", label: `Erreurs (${statusCounts.errors})`, color: "red" },
].map(({ key, label }) => (
<Button
key={key}
size="sm"
variant={statusFilter === key ? "default" : "outline"}
onClick={() => setStatusFilter(key)}
className={`text-xs ${statusFilter === key ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
{label}
</Button>
))}
</div>
{/* Séparateur */}
<div className="flex items-center gap-2 ml-auto">
<CalendarDays className="w-4 h-4 text-gray-400" />
{/* Année */}
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}>
<SelectTrigger className="w-28 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{availableYears.map(y => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
{/* Mois */}
<Select
value={monthFilter}
onValueChange={setMonthFilter}
disabled={yearFilter === "all"}
>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue placeholder="Tous les 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>
{(yearFilter !== String(new Date().getFullYear()) || monthFilter !== "all" || statusFilter !== "all") && (
<Button
size="sm"
variant="ghost"
className="text-xs text-gray-500 h-8"
onClick={() => { setYearFilter(String(new Date().getFullYear())); setMonthFilter("all"); setStatusFilter("all"); }}
>
Réinitialiser
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Tableau détaillé */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold text-gray-800">
Détail par fichier ({filteredLogs.length} entrée{filteredLogs.length > 1 ? "s" : ""})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mr-3" />
Chargement...
</div>
) : filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<FileText className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm">Aucun import pour cette période</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-xs font-semibold text-gray-600">Fichier</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Date import</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Détectées</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Importées</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Doublons</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Erreurs</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Statut</TableHead>
<TableHead className="text-xs font-semibold text-gray-600 text-center">Détails</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="hover:bg-gray-50 transition-colors">
<TableCell className="text-sm text-gray-800 max-w-xs truncate font-medium" title={log.fileName}>
{log.fileName}
</TableCell>
<TableCell className="text-xs text-gray-500 text-center whitespace-nowrap">
{new Date(log.importedAt).toLocaleDateString("fr-FR")}
<br />
<span className="text-gray-400">{new Date(log.importedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
</TableCell>
<TableCell className="text-center">
<span className="text-sm font-semibold text-purple-700">{log.totalInvoicesDetected}</span>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm font-semibold ${log.invoicesImported > 0 ? "text-green-700" : "text-gray-400"}`}>
{log.invoicesImported}
</span>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm font-semibold ${log.duplicatesIgnored > 0 ? "text-amber-600" : "text-gray-400"}`}>
{log.duplicatesIgnored}
</span>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm font-semibold ${log.errors > 0 ? "text-red-600" : "text-gray-400"}`}>
{log.errors}
</span>
</TableCell>
<TableCell className="text-center">
<StatusBadge
imported={log.invoicesImported}
duplicates={log.duplicatesIgnored}
errors={log.errors}
/>
</TableCell>
<TableCell className="text-center">
{(log.duplicatesIgnored > 0 || log.errors > 0) && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-blue-600 hover:bg-blue-50"
onClick={() => setSelectedLog(log as ImportLog)}
title="Voir les détails"
>
<Eye className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* Dialog détails */}
{selectedLog && (
<Dialog open={!!selectedLog} onOpenChange={() => setSelectedLog(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Détails de l'import
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 mb-1">Fichier</p>
<p className="text-sm font-medium text-gray-800 break-all">{selectedLog.fileName}</p>
<p className="text-xs text-gray-400 mt-1">
Importé le {new Date(selectedLog.importedAt).toLocaleDateString("fr-FR")} à{" "}
{new Date(selectedLog.importedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{/* Résumé chiffres */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: "Détectées", value: selectedLog.totalInvoicesDetected, color: "purple" },
{ label: "Importées", value: selectedLog.invoicesImported, color: "green" },
{ label: "Doublons", value: selectedLog.duplicatesIgnored, color: "amber" },
{ label: "Erreurs", value: selectedLog.errors, color: "red" },
].map(({ label, value, color }) => (
<div key={label} className={`bg-${color}-50 rounded-lg p-3 text-center`}>
<p className={`text-xl font-bold text-${color}-700`}>{value}</p>
<p className={`text-xs text-${color}-600`}>{label}</p>
</div>
))}
</div>
{/* Doublons */}
{selectedLog.duplicatesIgnored > 0 && (
<div>
<h4 className="text-sm font-semibold text-amber-700 mb-2 flex items-center gap-2">
<Copy className="w-4 h-4" />
Factures ignorées Doublons ({selectedLog.duplicatesIgnored})
</h4>
<div className="space-y-2">
{parseJson<DuplicateDetail>(selectedLog.duplicateDetails).map((dup, i) => (
<div key={i} className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm">
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-xs text-amber-600 font-medium">Fournisseur</p>
<p className="text-amber-800">{dup.supplierName || "—"}</p>
</div>
<div>
<p className="text-xs text-amber-600 font-medium">N° Facture</p>
<p className="text-amber-800">{dup.invoiceNumber || "—"}</p>
</div>
<div>
<p className="text-xs text-amber-600 font-medium">Date</p>
<p className="text-amber-800">
{dup.invoiceDate
? new Date(dup.invoiceDate).toLocaleDateString("fr-FR")
: "—"}
</p>
</div>
</div>
<p className="text-xs text-amber-500 mt-2">
Cette facture existe déjà en base elle a é ignorée pour éviter les doublons.
</p>
</div>
))}
{parseJson<DuplicateDetail>(selectedLog.duplicateDetails).length === 0 && (
<p className="text-sm text-amber-600 italic">Détails non disponibles</p>
)}
</div>
</div>
)}
{/* Erreurs */}
{selectedLog.errors > 0 && (
<div>
<h4 className="text-sm font-semibold text-red-700 mb-2 flex items-center gap-2">
<XCircle className="w-4 h-4" />
Erreurs d'extraction ({selectedLog.errors})
</h4>
<div className="space-y-2">
{parseJson<ErrorDetail>(selectedLog.errorDetails).map((err, i) => (
<div key={i} className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm">
<p className="text-red-700 font-medium">{err.message || "Erreur inconnue"}</p>
{err.field && (
<p className="text-xs text-red-500 mt-1">Champ : {err.field} = {err.value || "vide"}</p>
)}
</div>
))}
{parseJson<ErrorDetail>(selectedLog.errorDetails).length === 0 && (
<p className="text-sm text-red-600 italic">Détails non disponibles</p>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
)}
</DashboardLayout>
);
}