Checkpoint: Historique des imports enrichi avec le compte, la source, le déclencheur, les résultats, les erreurs et les informations du fichier source. Les administrateurs peuvent déclencher une lecture e-mail ponctuelle pour une boîte configurée, avec confirmation explicite, sans activer ni créer de planification. Migration importTrigger appliquée en sandbox ; TypeScript, 48 tests, build et rendus bureau/mobile validés.

This commit is contained in:
Manus
2026-09-02 11:43:23 +00:00
parent f53bb82256
commit 0b2f1b781d
12 changed files with 2834 additions and 421 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -12,20 +13,24 @@ import {
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import {
History as HistoryIcon,
FileText,
Trash2,
Eye,
CheckCircle2,
XCircle,
AlertTriangle,
CalendarDays,
RotateCcw,
Package,
X,
Upload,
CheckCircle2,
Clock3,
Eye,
FileText,
FolderOpen,
History as HistoryIcon,
Loader2,
Mail,
Package,
Play,
RotateCcw,
Trash2,
Upload,
UserRound,
X,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@@ -55,6 +60,39 @@ import {
} from "@/components/ui/select";
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors";
type SourceFilter = "all" | "email" | "folder" | "file";
type TriggerFilter = "all" | "manual" | "automatic" | "unknown";
type DetailedImportLog = {
id: number;
userId: number;
sourceFileId: number;
fileName: string;
totalInvoicesDetected: number;
invoicesImported: number;
duplicatesIgnored: number;
errors: number;
duplicateDetails: string | null;
errorDetails: string | null;
warningMessage: string | null;
importSource: "file" | "folder" | "email";
importTrigger: "manual" | "automatic" | "unknown";
importedAt: Date;
userName: string | null;
userEmail: string | null;
sourceCreatedAt: Date | null;
sourceStatus: "processing" | "completed" | "error" | null;
};
type EmailAccount = {
userId: number;
userName: string | null;
userEmail: string;
emailAddress: string | null;
authMode: "basic" | "oauth2";
automaticEnabled: number;
isConfigured: number;
};
const MONTHS = [
{ value: "1", label: "Janvier" },
@@ -71,111 +109,149 @@ const MONTHS = [
{ value: "12", label: "Décembre" },
];
const YEARS = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
const owner = account.userName?.trim() || account.userEmail;
return account.emailAddress ? `${owner}${account.emailAddress}` : owner;
}
function SourceBadge({ source }: { source: DetailedImportLog["importSource"] }) {
if (source === "email") {
return <Badge className="gap-1 border border-blue-200 bg-blue-100 text-blue-700 hover:bg-blue-100"><Mail className="h-3 w-3" />E-mail</Badge>;
}
if (source === "folder") {
return <Badge className="gap-1 border border-purple-200 bg-purple-100 text-purple-700 hover:bg-purple-100"><FolderOpen className="h-3 w-3" />Dossier</Badge>;
}
return <Badge className="gap-1 border border-slate-200 bg-slate-100 text-slate-700 hover:bg-slate-100"><Upload className="h-3 w-3" />Fichier</Badge>;
}
function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"] }) {
if (trigger === "manual") {
return <Badge variant="outline" className="border-teal-200 bg-teal-50 text-teal-700">Manuel</Badge>;
}
if (trigger === "automatic") {
return <Badge variant="outline" className="border-indigo-200 bg-indigo-50 text-indigo-700">Planifié</Badge>;
}
return <Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-500">Non tracé</Badge>;
}
export default function History() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
});
const deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
const utils = trpc.useUtils();
// Filtres
const detailedLogs = (logs ?? []) as DetailedImportLog[];
const accounts = (emailAccounts ?? []) as EmailAccount[];
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sourceFilter, setSourceFilter] = useState<SourceFilter>("all");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("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 [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState("none");
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
const selectedAccount = useMemo(
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
[configuredAccounts, selectedAccountId],
);
const handleDeleteAll = async () => {
try {
await deleteAllMutation.mutateAsync();
await utils.importLogs.getByUser.invalidate();
toast.success("Logs supprimés", {
description: "Tous les logs d'import ont été supprimés avec succès.",
});
toast.success("Historique supprimé", { description: "Les enregistrements visibles pour votre rôle ont été supprimés." });
} catch {
toast.error("Erreur", {
description: "Impossible de supprimer les logs d'import.",
});
toast.error("Impossible de supprimer lhistorique des imports.");
}
};
// 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]);
const handleManualEmailCheck = async () => {
if (!selectedAccount) return;
try {
toast.info("Lecture ponctuelle de la boîte e-mail en cours…");
const result = await checkAccountNowMutation.mutateAsync({ userId: selectedAccount.userId });
if (!result.success) {
toast.error(result.message);
return;
}
await utils.importLogs.getByUser.invalidate();
toast.success("Lecture e-mail terminée", { description: result.message });
} catch (error: any) {
toast.error(error?.message || "Impossible de vérifier cette boîte e-mail.");
} finally {
setConfirmManualCheckOpen(false);
}
};
const periodFiltered = useMemo(() => detailedLogs.filter((log) => {
const date = new Date(log.importedAt);
if (yearFilter !== "all" && date.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && date.getMonth() + 1 !== Number(monthFilter)) return false;
return true;
}), [detailedLogs, 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 };
const totalImported = periodFiltered.reduce((sum, log) => sum + log.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((sum, log) => sum + log.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((sum, log) => sum + log.errors, 0);
const totalDetected = periodFiltered.reduce((sum, log) => sum + log.totalInvoicesDetected, 0);
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported: totalDuplicates + totalErrors };
}, [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 filteredLogs = useMemo(() => periodFiltered.filter((log) => {
if (sourceFilter !== "all" && log.importSource !== sourceFilter) return false;
if (triggerFilter !== "all" && log.importTrigger !== triggerFilter) return false;
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, sourceFilter, statusFilter, triggerFilter]);
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" },
const statusButtons: { key: StatusFilter; label: string; count: number; base: string; active: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, base: "bg-slate-100 text-slate-700 hover:bg-slate-200", active: "bg-slate-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, base: "border border-green-200 bg-green-50 text-green-700 hover:bg-green-100", active: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, base: "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100", active: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, base: "border border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100", active: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, base: "border border-red-200 bg-red-50 text-red-700 hover:bg-red-100", active: "bg-red-600 text-white" },
];
const hasFilters = yearFilter !== "all" || monthFilter !== "all" || sourceFilter !== "all" || triggerFilter !== "all";
return (
<DashboardLayout>
<div className="space-y-6">
{/* En-tête */}
<div className="flex justify-between items-start">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm: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>
<p className="mt-1 text-gray-500">Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.</p>
</div>
{logs && logs.length > 0 && (
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Effacer les logs
</Button>
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer lhistorique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous les logs d'import. Cette opération est irréversible.
</AlertDialogDescription>
<AlertDialogTitle>Supprimer lhistorique ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAll}
disabled={deleteAllMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAllMutation.isPending ? "Suppression..." : "Supprimer"}
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -183,340 +259,108 @@ 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>
{isAdmin && (
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex gap-3">
<div className="rounded-lg bg-teal-600 p-2 text-white"><Mail className="h-5 w-5" /></div>
<div>
<CardTitle className="text-lg">Lecture e-mail ponctuelle</CardTitle>
<CardDescription>Déclenchez une seule vérification pour une boîte configurée, sans réactiver le traitement automatique.</CardDescription>
</div>
</div>
<Badge variant="outline" className="w-fit border-teal-200 bg-white text-teal-700">Aucune planification créée</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-2">
<label className="text-sm font-medium" htmlFor="manual-email-account">Compte à vérifier</label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId} disabled={accountsLoading || configuredAccounts.length === 0}>
<SelectTrigger id="manual-email-account" className="bg-white"><SelectValue placeholder={accountsLoading ? "Chargement des comptes…" : "Choisir une boîte e-mail"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Choisir une boîte e-mail</SelectItem>
{configuredAccounts.map((account) => <SelectItem key={account.userId} value={String(account.userId)}>{formatAccount(account)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<AlertDialog open={confirmManualCheckOpen} onOpenChange={setConfirmManualCheckOpen}>
<Button onClick={() => setConfirmManualCheckOpen(true)} disabled={!selectedAccount || checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}Vérifier maintenant
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer une lecture e-mail ponctuelle ?</AlertDialogTitle>
<AlertDialogDescription>
La boîte « {selectedAccount ? formatAccount(selectedAccount) : ""} » sera vérifiée une fois. Les nouveaux PDF pourront être analysés, mais limport automatique restera désactivé.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleManualEmailCheck} disabled={checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? "Vérification…" : "Lancer la vérification"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="border-green-200 bg-green-50"><CardContent className="flex items-center gap-3 p-4"><CheckCircle2 className="h-8 w-8 shrink-0 text-green-600" /><div><p className="text-2xl font-bold text-green-700">{counts.totalImported}</p><p className="text-xs font-medium text-green-600">Factures importées</p></div></CardContent></Card>
<Card className="border-orange-200 bg-orange-50"><CardContent className="flex items-center gap-3 p-4"><XCircle className="h-8 w-8 shrink-0 text-orange-500" /><div><p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p><p className="text-xs font-medium text-orange-500">Non importées</p></div></CardContent></Card>
<Card className="border-yellow-200 bg-yellow-50"><CardContent className="flex items-center gap-3 p-4"><RotateCcw className="h-8 w-8 shrink-0 text-yellow-600" /><div><p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p><p className="text-xs font-medium text-yellow-600">Doublons ignorés</p></div></CardContent></Card>
<Card className="border-red-200 bg-red-50"><CardContent className="flex items-center gap-3 p-4"><AlertTriangle className="h-8 w-8 shrink-0 text-red-500" /><div><p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p><p className="text-xs font-medium text-red-600">Erreurs</p></div></CardContent></Card>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3 items-center">
{/* Filtres statut */}
<Card className="border-blue-100 bg-blue-50/40"><CardContent className="flex flex-wrap items-center gap-3 p-4">
<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>
))}
{statusButtons.map((button) => <button key={button.key} onClick={() => setStatusFilter(button.key)} className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${statusFilter === button.key ? button.active : button.base}`}>
{button.label}<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-bold ${statusFilter === button.key ? "bg-white/20" : "bg-white/70"}`}>{button.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">
<div className="hidden h-6 w-px bg-blue-200 sm:block" />
<div className="flex flex-wrap 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>
)}
<Select value={yearFilter} onValueChange={(value) => { setYearFilter(value); if (value === "all") setMonthFilter("all"); }}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Année" /></SelectTrigger><SelectContent><SelectItem value="all">Toute année</SelectItem>{YEARS.map((year) => <SelectItem key={year} value={year}>{year}</SelectItem>)}</SelectContent></Select>
<Select value={monthFilter} onValueChange={setMonthFilter} disabled={yearFilter === "all"}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Mois" /></SelectTrigger><SelectContent><SelectItem value="all">Tous les mois</SelectItem>{MONTHS.map((month) => <SelectItem key={month.value} value={month.value}>{month.label}</SelectItem>)}</SelectContent></Select>
<Select value={sourceFilter} onValueChange={(value) => setSourceFilter(value as SourceFilter)}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Source" /></SelectTrigger><SelectContent><SelectItem value="all">Toute source</SelectItem><SelectItem value="email">E-mail</SelectItem><SelectItem value="folder">Dossier</SelectItem><SelectItem value="file">Fichier</SelectItem></SelectContent></Select>
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
</div>
<span className="ml-auto text-sm text-gray-500">{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
</CardContent></Card>
{/* 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>
<CardContent className="p-0">
{isLoading ? (
<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 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">Source</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>
{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 className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected}
</Badge>
</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 font-mono">
{log.duplicatesIgnored}
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">
{log.errors > 0 ? (
<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-12 text-gray-500">
<HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<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>
<Card><CardContent className="overflow-x-auto p-0">
{isLoading ? <div className="py-12 text-center text-gray-500"><Package className="mx-auto mb-3 h-10 w-10 animate-pulse text-gray-300" /><p>Chargement de lhistorique</p></div> : filteredLogs.length > 0 ? (
<Table><TableHeader><TableRow className="bg-gray-50"><TableHead className="font-semibold">Fichier importé</TableHead><TableHead className="font-semibold">Date et heure</TableHead><TableHead className="font-semibold">Compte</TableHead><TableHead className="text-center font-semibold">Source</TableHead><TableHead className="text-center font-semibold">Mode</TableHead><TableHead className="text-center font-semibold">Résultat</TableHead><TableHead className="w-20 text-center font-semibold">Détails</TableHead></TableRow></TableHeader>
<TableBody>{filteredLogs.map((log) => <TableRow key={log.id} className="hover:bg-gray-50/50"><TableCell className="max-w-xs font-medium" title={log.fileName}><div className="flex items-center gap-2"><FileText className="h-4 w-4 shrink-0 text-blue-500" /><span className="truncate">{log.fileName}</span></div></TableCell><TableCell className="whitespace-nowrap text-sm text-gray-600"><div className="flex items-center gap-1"><Clock3 className="h-3.5 w-3.5 text-gray-400" />{new Date(log.importedAt).toLocaleString("fr-FR")}</div></TableCell><TableCell className="max-w-48 text-sm text-gray-600"><div className="flex items-center gap-1.5 truncate"><UserRound className="h-3.5 w-3.5 shrink-0 text-gray-400" /><span className="truncate" title={log.userEmail || "Compte supprimé"}>{log.userName?.trim() || log.userEmail || "Compte supprimé"}</span></div></TableCell><TableCell className="text-center"><SourceBadge source={log.importSource} /></TableCell><TableCell className="text-center"><TriggerBadge trigger={log.importTrigger} /></TableCell><TableCell className="text-center"><span className="font-mono text-sm text-green-700">+{log.invoicesImported}</span>{log.duplicatesIgnored > 0 && <span className="ml-1 font-mono text-xs text-yellow-700">/ {log.duplicatesIgnored} d.</span>}{log.errors > 0 && <span className="ml-1 font-mono text-xs text-red-700">/ {log.errors} e.</span>}</TableCell><TableCell className="text-center"><Button size="sm" variant="outline" className="h-8 px-2 text-blue-600" 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="py-12 text-center text-gray-500"><HistoryIcon className="mx-auto mb-3 h-12 w-12 text-gray-300" /><p className="font-medium">Aucun import trouvé</p><p className="mt-1 text-sm">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 className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</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>
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}><DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto"><DialogHeader><DialogTitle className="flex items-center gap-2"><FileText className="h-5 w-5 text-blue-500" />Détails de limport</DialogTitle></DialogHeader>
{selectedLog && <div className="space-y-4"><div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier</p><p className="break-all text-sm font-semibold">{selectedLog.fileName}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Date et heure dimport</p><p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Compte responsable</p><p className="text-sm font-semibold">{selectedLog.userName?.trim() || selectedLog.userEmail || "Compte supprimé"}</p>{selectedLog.userName && selectedLog.userEmail && <p className="mt-0.5 text-xs text-gray-500">{selectedLog.userEmail}</p>}</div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Origine</p><div className="flex items-center gap-2"><SourceBadge source={selectedLog.importSource} /><TriggerBadge trigger={selectedLog.importTrigger} /></div></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier source</p><p className="text-sm font-semibold">ID {selectedLog.sourceFileId} · {selectedLog.sourceStatus || "indisponible"}</p>{selectedLog.sourceCreatedAt && <p className="mt-0.5 text-xs text-gray-500">Créé le {new Date(selectedLog.sourceCreatedAt).toLocaleString("fr-FR")}</p>}</div></div>
<div className="grid grid-cols-4 gap-2"><div className="rounded-lg bg-gray-50 p-3 text-center"><p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p><p className="mt-1 text-xs text-gray-500">Détectées</p></div><div className="rounded-lg bg-green-50 p-3 text-center"><p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p><p className="mt-1 text-xs text-green-600">Importées</p></div><div className="rounded-lg bg-yellow-50 p-3 text-center"><p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p><p className="mt-1 text-xs text-yellow-600">Doublons</p></div><div className="rounded-lg bg-red-50 p-3 text-center"><p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p><p className="mt-1 text-xs text-red-600">Erreurs</p></div></div>
{selectedLog.warningMessage && <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><strong>Avertissement :</strong> {selectedLog.warningMessage}</div>}
{selectedLog.duplicateDetails && <DetailsList title="Doublons ignorés" tone="yellow" value={selectedLog.duplicateDetails} icon={<RotateCcw className="h-4 w-4" />} />}
{selectedLog.errorDetails && <DetailsList title="Erreurs" tone="red" value={selectedLog.errorDetails} icon={<AlertTriangle className="h-4 w-4" />} />}
</div>}
</DialogContent></Dialog>
</DashboardLayout>
);
}
function DetailsList({ title, tone, value, icon }: { title: string; tone: "yellow" | "red"; value: string; icon: React.ReactNode }) {
try {
const details = JSON.parse(value);
if (!Array.isArray(details) || details.length === 0) return null;
const styles = tone === "yellow" ? "border-yellow-100 bg-yellow-50 text-yellow-800" : "border-red-100 bg-red-50 text-red-700";
const heading = tone === "yellow" ? "text-yellow-700" : "text-red-600";
return <div><h3 className={`mb-2 flex items-center gap-1 text-sm font-semibold ${heading}`}>{icon}{title} ({details.length})</h3><div className="max-h-40 space-y-1 overflow-y-auto">{details.map((detail: string | Record<string, unknown>, index: number) => <div key={index} className={`rounded border px-3 py-1.5 text-xs ${styles}`}>{typeof detail === "string" ? detail : Object.values(detail).filter(Boolean).join(" — ")}</div>)}</div></div>;
} catch {
return null;
}
}