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:
@@ -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 l’historique 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 l’historique</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 l’historique ?</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 l’import 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 l’historique…</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 l’import</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 d’import</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;
|
||||
}
|
||||
}
|
||||
|
||||
1
drizzle/0038_late_thunderbolt.sql
Normal file
1
drizzle/0038_late_thunderbolt.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `importLogs` ADD `importTrigger` enum('manual','automatic','unknown') DEFAULT 'unknown' NOT NULL;
|
||||
2380
drizzle/meta/0038_snapshot.json
Normal file
2380
drizzle/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,13 @@
|
||||
"when": 1787394418464,
|
||||
"tag": "0037_goofy_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "5",
|
||||
"when": 1788349114141,
|
||||
"tag": "0038_late_thunderbolt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -231,6 +231,8 @@ export const importLogs = mysqlTable("importLogs", {
|
||||
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
|
||||
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
|
||||
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
|
||||
/** Déclencheur : manuel, planifié automatiquement, ou non tracé pour les historiques antérieurs. */
|
||||
importTrigger: mysqlEnum("importTrigger", ["manual", "automatic", "unknown"]).default("unknown").notNull(),
|
||||
importedAt: timestamp("importedAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
||||
58
server/db.ts
58
server/db.ts
@@ -1,4 +1,4 @@
|
||||
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
||||
import { eq, and, desc, sql, inArray, getTableColumns } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import {
|
||||
InsertUser,
|
||||
@@ -538,6 +538,44 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
|
||||
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les informations nécessaires à l'audit d'un import sans exposer
|
||||
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
|
||||
* chaque import à son compte, fichier source et déclencheur.
|
||||
*/
|
||||
export async function getImportLogsWithDetailsByUser(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select({
|
||||
...getTableColumns(importLogs),
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
sourceCreatedAt: sourceFiles.createdAt,
|
||||
sourceStatus: sourceFiles.processingStatus,
|
||||
})
|
||||
.from(importLogs)
|
||||
.leftJoin(users, eq(importLogs.userId, users.id))
|
||||
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
||||
.where(eq(importLogs.userId, userId))
|
||||
.orderBy(desc(importLogs.importedAt));
|
||||
}
|
||||
|
||||
export async function getAllImportLogsWithDetails() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select({
|
||||
...getTableColumns(importLogs),
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
sourceCreatedAt: sourceFiles.createdAt,
|
||||
sourceStatus: sourceFiles.processingStatus,
|
||||
})
|
||||
.from(importLogs)
|
||||
.leftJoin(users, eq(importLogs.userId, users.id))
|
||||
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
|
||||
.orderBy(desc(importLogs.importedAt));
|
||||
}
|
||||
|
||||
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
@@ -611,6 +649,24 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/** Liste administrative des comptes e-mail configurés, sans secret IMAP ni Azure AD. */
|
||||
export async function getEmailImportAccounts() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select({
|
||||
userId: importSettings.userId,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
emailAddress: importSettings.emailImportAddress,
|
||||
authMode: importSettings.emailImportAuthMode,
|
||||
automaticEnabled: importSettings.emailImportEnabled,
|
||||
isConfigured: sql<number>`CASE WHEN ${importSettings.emailImportAddress} IS NOT NULL AND ${importSettings.emailImportAddress} <> '' AND ${importSettings.emailImportHost} IS NOT NULL AND ${importSettings.emailImportHost} <> '' THEN 1 ELSE 0 END`.as("isConfigured"),
|
||||
})
|
||||
.from(importSettings)
|
||||
.innerJoin(users, eq(importSettings.userId, users.id))
|
||||
.orderBy(users.name, users.email);
|
||||
}
|
||||
|
||||
// ============= DEPARTMENT LIST OPERATIONS =============
|
||||
|
||||
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createImapFlowOptions, type EmailImportConfig } from "./emailImportService";
|
||||
import {
|
||||
createImapFlowOptions,
|
||||
type EmailImportConfig,
|
||||
validateEmailImportConfiguration,
|
||||
} from "./emailImportService";
|
||||
|
||||
const baseConfig: EmailImportConfig = {
|
||||
userId: 2,
|
||||
@@ -36,3 +40,39 @@ describe("createImapFlowOptions", () => {
|
||||
expect(options.auth).not.toHaveProperty("accessToken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateEmailImportConfiguration", () => {
|
||||
it("accepte une boîte OAuth2 configurée même lorsque la planification automatique est désactivée", () => {
|
||||
const error = validateEmailImportConfiguration({
|
||||
emailImportEnabled: 0,
|
||||
emailImportAddress: "compta@example.org",
|
||||
emailImportPassword: null,
|
||||
emailImportHost: "outlook.office365.com",
|
||||
emailImportPort: 993,
|
||||
emailImportSinceDate: null,
|
||||
emailImportAuthMode: "oauth2",
|
||||
azureTenantId: "tenant",
|
||||
azureClientId: "client",
|
||||
azureClientSecret: "secret",
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it("refuse une boîte dont la configuration IMAP est incomplète", () => {
|
||||
const error = validateEmailImportConfiguration({
|
||||
emailImportEnabled: 0,
|
||||
emailImportAddress: "compta@example.org",
|
||||
emailImportPassword: null,
|
||||
emailImportHost: null,
|
||||
emailImportPort: 993,
|
||||
emailImportSinceDate: null,
|
||||
emailImportAuthMode: "oauth2",
|
||||
azureTenantId: "tenant",
|
||||
azureClientId: "client",
|
||||
azureClientSecret: "secret",
|
||||
});
|
||||
|
||||
expect(error).toBe("Configuration IMAP incomplète");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,40 @@ export interface EmailImportConfig {
|
||||
azureClientSecret?: string;
|
||||
}
|
||||
|
||||
export type EmailImportTrigger = "manual" | "automatic";
|
||||
|
||||
type EmailImportSettingsSnapshot = {
|
||||
emailImportEnabled: number;
|
||||
emailImportAddress: string | null;
|
||||
emailImportPassword: string | null;
|
||||
emailImportHost: string | null;
|
||||
emailImportPort: number | null;
|
||||
emailImportSinceDate: number | null;
|
||||
emailImportAuthMode: "basic" | "oauth2";
|
||||
azureTenantId: string | null;
|
||||
azureClientId: string | null;
|
||||
azureClientSecret: string | null;
|
||||
};
|
||||
|
||||
/** Vérifie une configuration IMAP sans tenir compte de l'activation de la planification. */
|
||||
export function validateEmailImportConfiguration(
|
||||
settings: EmailImportSettingsSnapshot | null | undefined,
|
||||
): string | null {
|
||||
if (!settings || !settings.emailImportAddress || !settings.emailImportHost) {
|
||||
return "Configuration IMAP incomplète";
|
||||
}
|
||||
if (settings.emailImportAuthMode === "basic" && !settings.emailImportPassword) {
|
||||
return "Mot de passe IMAP manquant";
|
||||
}
|
||||
if (
|
||||
settings.emailImportAuthMode === "oauth2" &&
|
||||
(!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)
|
||||
) {
|
||||
return "Configuration OAuth2 IMAP incomplète";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les options ImapFlow sans effectuer d'appel réseau.
|
||||
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2.
|
||||
@@ -67,14 +101,17 @@ const activeIntervals = new Map<number, NodeJS.Timeout>();
|
||||
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
|
||||
const activeChecks = new Map<number, Promise<void>>();
|
||||
|
||||
function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
|
||||
function runEmailCheckExclusive(
|
||||
config: EmailImportConfig,
|
||||
trigger: EmailImportTrigger = "automatic",
|
||||
): Promise<void> {
|
||||
const runningCheck = activeChecks.get(config.userId);
|
||||
if (runningCheck) {
|
||||
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
|
||||
return runningCheck;
|
||||
}
|
||||
|
||||
const check = checkEmailsForPDFs(config).finally(() => {
|
||||
const check = checkEmailsForPDFs(config, trigger).finally(() => {
|
||||
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
|
||||
});
|
||||
activeChecks.set(config.userId, check);
|
||||
@@ -88,7 +125,8 @@ function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
|
||||
async function processEmailAttachment(
|
||||
userId: number,
|
||||
attachment: Attachment,
|
||||
emailSubject: string
|
||||
emailSubject: string,
|
||||
trigger: EmailImportTrigger,
|
||||
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
|
||||
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
|
||||
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
|
||||
@@ -323,6 +361,7 @@ async function processEmailAttachment(
|
||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||
warningMessage,
|
||||
importSource: "email",
|
||||
importTrigger: trigger,
|
||||
});
|
||||
|
||||
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
|
||||
@@ -386,7 +425,10 @@ async function buildImapConfig(config: EmailImportConfig): Promise<ImapFlowOptio
|
||||
/**
|
||||
* Connect to IMAP and process unread emails with PDF attachments
|
||||
*/
|
||||
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
||||
async function checkEmailsForPDFs(
|
||||
config: EmailImportConfig,
|
||||
trigger: EmailImportTrigger,
|
||||
): Promise<void> {
|
||||
const imapConfig = await buildImapConfig(config);
|
||||
const client = new ImapFlow(imapConfig);
|
||||
client.on("error", (error) => {
|
||||
@@ -450,6 +492,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
||||
config.userId,
|
||||
attachment,
|
||||
parsed.subject || "No subject",
|
||||
trigger,
|
||||
);
|
||||
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
|
||||
|
||||
@@ -615,29 +658,28 @@ export function isEmailImportServiceRunning(userId: number): boolean {
|
||||
return activeIntervals.has(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger an immediate email check for a user
|
||||
*/
|
||||
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
|
||||
async function triggerConfiguredEmailCheck(
|
||||
userId: number,
|
||||
requireAutomaticEnabled: boolean,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Get user's import settings
|
||||
const settings = await getImportSettingsByUser(userId);
|
||||
|
||||
if (!settings || settings.emailImportEnabled !== 1) {
|
||||
if (!settings || (requireAutomaticEnabled && settings.emailImportEnabled !== 1)) {
|
||||
return { success: false, message: "Import par email non activé" };
|
||||
}
|
||||
|
||||
if (!settings.emailImportAddress || !settings.emailImportHost) {
|
||||
return { success: false, message: "Configuration IMAP incomplète" };
|
||||
}
|
||||
const validationError = validateEmailImportConfiguration(settings);
|
||||
if (validationError) return { success: false, message: validationError };
|
||||
|
||||
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
|
||||
|
||||
const config: EmailImportConfig = {
|
||||
userId,
|
||||
emailAddress: settings.emailImportAddress,
|
||||
// validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
|
||||
emailAddress: settings.emailImportAddress!,
|
||||
password: settings.emailImportPassword || "",
|
||||
host: settings.emailImportHost,
|
||||
host: settings.emailImportHost!,
|
||||
port: settings.emailImportPort || 993,
|
||||
sinceDate: settings.emailImportSinceDate ?? undefined,
|
||||
authMode,
|
||||
@@ -647,7 +689,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
|
||||
};
|
||||
|
||||
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
|
||||
await runEmailCheckExclusive(config);
|
||||
// Cette voie exécute une vérification unique : elle ne crée pas de setInterval.
|
||||
await runEmailCheckExclusive(config, "manual");
|
||||
|
||||
return { success: true, message: "Vérification terminée avec succès" };
|
||||
} catch (error: any) {
|
||||
@@ -656,6 +699,16 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
|
||||
}
|
||||
}
|
||||
|
||||
/** Déclenchement manuel réservé aux appels administrateurs, même si le planificateur est désactivé. */
|
||||
export async function triggerManualEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
|
||||
return triggerConfiguredEmailCheck(userId, false);
|
||||
}
|
||||
|
||||
/** Compatibilité avec le bouton existant : la vérification personnelle exige toujours l’activation automatique. */
|
||||
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
|
||||
return triggerConfiguredEmailCheck(userId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all email import services
|
||||
*/
|
||||
|
||||
@@ -211,6 +211,7 @@ async function processFolderFile(
|
||||
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||
importSource: "folder",
|
||||
importTrigger: "automatic",
|
||||
});
|
||||
|
||||
// Move file to processed folder
|
||||
|
||||
@@ -40,13 +40,16 @@ import {
|
||||
isInvoiceBlacklisted,
|
||||
getAllInvoices,
|
||||
getAllImportLogs,
|
||||
getAllImportLogsWithDetails,
|
||||
getAllBapHistory,
|
||||
createImportLog,
|
||||
getImportLogsByUser,
|
||||
getImportLogsWithDetailsByUser,
|
||||
deleteAllImportLogs,
|
||||
getLlmLogsBySourceFile,
|
||||
getLlmLogsByInvoice,
|
||||
getImportSettingsByUser,
|
||||
getEmailImportAccounts,
|
||||
upsertImportSettings,
|
||||
getDepartmentsByUser,
|
||||
createDepartment,
|
||||
@@ -94,7 +97,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
|
||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||
import { drawBapCartouche } from "./bapCartouche";
|
||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
|
||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { processFreeproExcel } from "./freeproService";
|
||||
@@ -409,6 +412,7 @@ export const appRouter = router({
|
||||
duplicateDetails: JSON.stringify(duplicateDetails),
|
||||
errorDetails: JSON.stringify(errorDetails),
|
||||
importSource: "file",
|
||||
importTrigger: "manual",
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -1646,9 +1650,9 @@ export const appRouter = router({
|
||||
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Les admins voient tous les logs d'import
|
||||
if (ctx.user.role === 'admin') {
|
||||
return getAllImportLogs();
|
||||
return getAllImportLogsWithDetails();
|
||||
}
|
||||
return getImportLogsByUser(ctx.user.id);
|
||||
return getImportLogsWithDetailsByUser(ctx.user.id);
|
||||
}),
|
||||
|
||||
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
@@ -1715,6 +1719,18 @@ export const appRouter = router({
|
||||
const result = await triggerEmailCheck(ctx.user.id);
|
||||
return result;
|
||||
}),
|
||||
|
||||
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle d’administrateur. */
|
||||
getConfiguredAccounts: adminProcedure.query(async () => {
|
||||
return getEmailImportAccounts();
|
||||
}),
|
||||
|
||||
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
|
||||
checkAccountNow: adminProcedure
|
||||
.input(z.object({ userId: z.number().int().positive() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return triggerManualEmailCheck(input.userId);
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= IMPORT SETTINGS ROUTES =============
|
||||
|
||||
7
todo.md
7
todo.md
@@ -846,3 +846,10 @@
|
||||
- [x] Identifier les deux factures créées et leur source d’import sans modifier les données
|
||||
- [x] Corréler les factures avec l’historique, les fichiers source et les journaux applicatifs
|
||||
- [x] Vérifier l’état de l’import e-mail au moment de leur création et déterminer la cause : le compte utilisateur 2 était activé, avec une cadence de 30 minutes
|
||||
|
||||
## Historique détaillé et déclenchement manuel des imports
|
||||
- [x] Analyser les données d’import, les routes et la page Historique existantes
|
||||
- [x] Ajouter une vue détaillée de l’historique avec source, compte, date, résultats et erreurs
|
||||
- [x] Ajouter une action administrateur d’import e-mail manuel par compte configuré
|
||||
- [x] Garantir que le déclenchement manuel ne modifie jamais la planification automatique
|
||||
- [x] Ajouter les tests de non-régression et valider le parcours en sandbox
|
||||
|
||||
6
verification_notes.md
Normal file
6
verification_notes.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Vérification visuelle — Historique des imports
|
||||
|
||||
- Le 2 septembre 2026, la page `/history` a été vérifiée en format bureau et mobile.
|
||||
- Le cartouche administrateur de lecture e-mail ponctuelle est visible, explicite sur l’absence de planification créée et ne propose aucune activation automatique.
|
||||
- Les filtres et indicateurs restent lisibles sur mobile.
|
||||
- Le tableau est enveloppé dans un conteneur à défilement horizontal afin de préserver toutes les colonnes de traçabilité sur les écrans étroits.
|
||||
Reference in New Issue
Block a user