feat: page rapport détaillé des imports avec filtres, compteurs et détails doublons/erreurs
This commit is contained in:
@@ -18,6 +18,7 @@ import Users from "./pages/Users";
|
|||||||
import ListsAdmin from "./pages/ListsAdmin";
|
import ListsAdmin from "./pages/ListsAdmin";
|
||||||
import AutomationRules from "./pages/AutomationRules";
|
import AutomationRules from "./pages/AutomationRules";
|
||||||
import BapHistory from "./pages/BapHistory";
|
import BapHistory from "./pages/BapHistory";
|
||||||
|
import ImportReport from "./pages/ImportReport";
|
||||||
import LearningSettings from "./pages/LearningSettings";
|
import LearningSettings from "./pages/LearningSettings";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
@@ -37,6 +38,7 @@ function Router() {
|
|||||||
<Route path="/lists-admin" component={ListsAdmin} />
|
<Route path="/lists-admin" component={ListsAdmin} />
|
||||||
<Route path="/automation-rules" component={AutomationRules} />
|
<Route path="/automation-rules" component={AutomationRules} />
|
||||||
<Route path="/bap-history" component={BapHistory} />
|
<Route path="/bap-history" component={BapHistory} />
|
||||||
|
<Route path="/import-report" component={ImportReport} />
|
||||||
<Route path="/learning-settings" component={LearningSettings} />
|
<Route path="/learning-settings" component={LearningSettings} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
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 { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -76,6 +76,7 @@ const menuStructure: MenuItem[] = [
|
|||||||
color: "from-green-500 to-emerald-500",
|
color: "from-green-500 to-emerald-500",
|
||||||
children: [
|
children: [
|
||||||
{ icon: History, label: "Historiques", path: "/history" },
|
{ icon: History, label: "Historiques", path: "/history" },
|
||||||
|
{ icon: BarChart2, label: "Rapport imports", path: "/import-report" },
|
||||||
{ icon: CheckSquare, label: "Historique BAP", path: "/bap-history" },
|
{ icon: CheckSquare, label: "Historique BAP", path: "/bap-history" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
517
client/src/pages/ImportReport.tsx
Normal file
517
client/src/pages/ImportReport.tsx
Normal 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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user