diff --git a/client/src/App.tsx b/client/src/App.tsx index a944812..b10a1a4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 05b45e1..ad1966b 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -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" }, ], }, diff --git a/client/src/pages/ImportReport.tsx b/client/src/pages/ImportReport.tsx new file mode 100644 index 0000000..706392f --- /dev/null +++ b/client/src/pages/ImportReport.tsx @@ -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(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 Erreurs ({errors}); + if (duplicates > 0) return Doublons ({duplicates}); + if (imported > 0) return Importée; + return Ignorée; +} + +export default function ImportReport() { + const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); + const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear())); + const [monthFilter, setMonthFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedLog, setSelectedLog] = useState(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 ( + +
+ {/* En-tête */} +
+
+

Rapport des imports

+

Analyse détaillée des factures importées et non importées

+
+ +
+ + {/* Compteurs synthèse */} +
+ + +
+ + Fichiers +
+

{totals.files}

+

imports traités

+
+
+ + +
+ + Détectées +
+

{totals.detected}

+

factures trouvées

+
+
+ + +
+ + Importées +
+

{totals.imported}

+

taux : {importRate}%

+
+
+ + +
+ + Doublons +
+

{totals.duplicates}

+

ignorés

+
+
+ + +
+ + Erreurs +
+

{totals.errors}

+

échecs extraction

+
+
+
+ + {/* Barre de progression globale */} + {totals.detected > 0 && ( + + +
+ Taux d'import global + {importRate}% ({totals.imported}/{totals.detected}) +
+
+
+
+
+
+
+ Importées + Doublons + Erreurs +
+ + + )} + + {/* Filtres */} + + +
+ {/* Filtres statut */} +
+ {[ + { 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 }) => ( + + ))} +
+ + {/* Séparateur */} +
+ + {/* Année */} + + {/* Mois */} + + {(yearFilter !== String(new Date().getFullYear()) || monthFilter !== "all" || statusFilter !== "all") && ( + + )} +
+
+
+
+ + {/* Tableau détaillé */} + + + + Détail par fichier ({filteredLogs.length} entrée{filteredLogs.length > 1 ? "s" : ""}) + + + + {isLoading ? ( +
+
+ Chargement... +
+ ) : filteredLogs.length === 0 ? ( +
+ +

Aucun import pour cette période

+
+ ) : ( + + + + Fichier + Date import + Détectées + Importées + Doublons + Erreurs + Statut + Détails + + + + {filteredLogs.map((log) => ( + + + {log.fileName} + + + {new Date(log.importedAt).toLocaleDateString("fr-FR")} +
+ {new Date(log.importedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} +
+ + {log.totalInvoicesDetected} + + + 0 ? "text-green-700" : "text-gray-400"}`}> + {log.invoicesImported} + + + + 0 ? "text-amber-600" : "text-gray-400"}`}> + {log.duplicatesIgnored} + + + + 0 ? "text-red-600" : "text-gray-400"}`}> + {log.errors} + + + + + + + {(log.duplicatesIgnored > 0 || log.errors > 0) && ( + + )} + +
+ ))} +
+
+ )} + + +
+ + {/* Dialog détails */} + {selectedLog && ( + setSelectedLog(null)}> + + + + + Détails de l'import + + +
+
+

Fichier

+

{selectedLog.fileName}

+

+ Importé le {new Date(selectedLog.importedAt).toLocaleDateString("fr-FR")} à{" "} + {new Date(selectedLog.importedAt).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} +

+
+ + {/* Résumé chiffres */} +
+ {[ + { 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 }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ + {/* Doublons */} + {selectedLog.duplicatesIgnored > 0 && ( +
+

+ + Factures ignorées — Doublons ({selectedLog.duplicatesIgnored}) +

+
+ {parseJson(selectedLog.duplicateDetails).map((dup, i) => ( +
+
+
+

Fournisseur

+

{dup.supplierName || "—"}

+
+
+

N° Facture

+

{dup.invoiceNumber || "—"}

+
+
+

Date

+

+ {dup.invoiceDate + ? new Date(dup.invoiceDate).toLocaleDateString("fr-FR") + : "—"} +

+
+
+

+ ⚠ Cette facture existe déjà en base — elle a été ignorée pour éviter les doublons. +

+
+ ))} + {parseJson(selectedLog.duplicateDetails).length === 0 && ( +

Détails non disponibles

+ )} +
+
+ )} + + {/* Erreurs */} + {selectedLog.errors > 0 && ( +
+

+ + Erreurs d'extraction ({selectedLog.errors}) +

+
+ {parseJson(selectedLog.errorDetails).map((err, i) => ( +
+

{err.message || "Erreur inconnue"}

+ {err.field && ( +

Champ : {err.field} = {err.value || "vide"}

+ )} +
+ ))} + {parseJson(selectedLog.errorDetails).length === 0 && ( +

Détails non disponibles

+ )} +
+
+ )} +
+
+
+ )} + + ); +}