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 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -12,20 +13,24 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import {
|
import {
|
||||||
History as HistoryIcon,
|
|
||||||
FileText,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
RotateCcw,
|
CheckCircle2,
|
||||||
Package,
|
Clock3,
|
||||||
X,
|
Eye,
|
||||||
Upload,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
History as HistoryIcon,
|
||||||
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
|
Package,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -55,6 +60,39 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors";
|
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 = [
|
const MONTHS = [
|
||||||
{ value: "1", label: "Janvier" },
|
{ value: "1", label: "Janvier" },
|
||||||
@@ -71,111 +109,149 @@ const MONTHS = [
|
|||||||
{ value: "12", label: "Décembre" },
|
{ 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() {
|
export default function History() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
|
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 deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
|
||||||
|
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
|
||||||
const utils = trpc.useUtils();
|
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 [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 [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
|
||||||
const [monthFilter, setMonthFilter] = useState<string>("all");
|
const [monthFilter, setMonthFilter] = useState<string>("all");
|
||||||
|
|
||||||
// Dialog visualisation détails
|
|
||||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
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 () => {
|
const handleDeleteAll = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllMutation.mutateAsync();
|
await deleteAllMutation.mutateAsync();
|
||||||
await utils.importLogs.getByUser.invalidate();
|
await utils.importLogs.getByUser.invalidate();
|
||||||
toast.success("Logs supprimés", {
|
toast.success("Historique supprimé", { description: "Les enregistrements visibles pour votre rôle ont été supprimés." });
|
||||||
description: "Tous les logs d'import ont été supprimés avec succès.",
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Erreur", {
|
toast.error("Impossible de supprimer l’historique des imports.");
|
||||||
description: "Impossible de supprimer les logs d'import.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrage par période
|
const handleManualEmailCheck = async () => {
|
||||||
const periodFiltered = useMemo(() => {
|
if (!selectedAccount) return;
|
||||||
if (!logs) return [];
|
try {
|
||||||
return logs.filter((log) => {
|
toast.info("Lecture ponctuelle de la boîte e-mail en cours…");
|
||||||
const d = new Date(log.importedAt);
|
const result = await checkAccountNowMutation.mutateAsync({ userId: selectedAccount.userId });
|
||||||
if (yearFilter !== "all" && d.getFullYear() !== Number(yearFilter)) return false;
|
if (!result.success) {
|
||||||
if (monthFilter !== "all" && (d.getMonth() + 1) !== Number(monthFilter)) return false;
|
toast.error(result.message);
|
||||||
return true;
|
return;
|
||||||
});
|
}
|
||||||
}, [logs, yearFilter, monthFilter]);
|
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 counts = useMemo(() => {
|
||||||
const totalImported = periodFiltered.reduce((s, l) => s + l.invoicesImported, 0);
|
const totalImported = periodFiltered.reduce((sum, log) => sum + log.invoicesImported, 0);
|
||||||
const totalDuplicates = periodFiltered.reduce((s, l) => s + l.duplicatesIgnored, 0);
|
const totalDuplicates = periodFiltered.reduce((sum, log) => sum + log.duplicatesIgnored, 0);
|
||||||
const totalErrors = periodFiltered.reduce((s, l) => s + l.errors, 0);
|
const totalErrors = periodFiltered.reduce((sum, log) => sum + log.errors, 0);
|
||||||
const totalDetected = periodFiltered.reduce((s, l) => s + l.totalInvoicesDetected, 0);
|
const totalDetected = periodFiltered.reduce((sum, log) => sum + log.totalInvoicesDetected, 0);
|
||||||
const totalNotImported = totalDuplicates + totalErrors;
|
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported: totalDuplicates + totalErrors };
|
||||||
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported };
|
|
||||||
}, [periodFiltered]);
|
}, [periodFiltered]);
|
||||||
|
|
||||||
// Filtrage par statut
|
const filteredLogs = useMemo(() => periodFiltered.filter((log) => {
|
||||||
const filteredLogs = useMemo(() => {
|
if (sourceFilter !== "all" && log.importSource !== sourceFilter) return false;
|
||||||
return periodFiltered.filter((log) => {
|
if (triggerFilter !== "all" && log.importTrigger !== triggerFilter) return false;
|
||||||
if (statusFilter === "all") return true;
|
if (statusFilter === "imported") return log.invoicesImported > 0;
|
||||||
if (statusFilter === "imported") return log.invoicesImported > 0;
|
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
|
||||||
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
|
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
|
||||||
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
|
if (statusFilter === "errors") return log.errors > 0;
|
||||||
if (statusFilter === "errors") return log.errors > 0;
|
return true;
|
||||||
return true;
|
}), [periodFiltered, sourceFilter, statusFilter, triggerFilter]);
|
||||||
});
|
|
||||||
}, [periodFiltered, statusFilter]);
|
|
||||||
|
|
||||||
const hasPeriodFilter = yearFilter !== "all" || monthFilter !== "all";
|
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" },
|
||||||
const filterButtons: { key: StatusFilter; label: string; count: number; color: string; activeColor: string }[] = [
|
{ 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: "all", label: "Tous", count: periodFiltered.length, color: "bg-gray-100 text-gray-700 hover:bg-gray-200", activeColor: "bg-gray-700 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: "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: "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: "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: "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" },
|
||||||
{ 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 hasFilters = yearFilter !== "all" || monthFilter !== "all" || sourceFilter !== "all" || triggerFilter !== "all";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* En-tête */}
|
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Historique des imports</h1>
|
<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>
|
</div>
|
||||||
{logs && logs.length > 0 && (
|
{detailedLogs.length > 0 && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" size="sm">
|
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer l’historique</Button>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Effacer les logs
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
<AlertDialogTitle>Supprimer l’historique ?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
|
||||||
Cette action supprimera définitivement tous les logs d'import. Cette opération est irréversible.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
onClick={handleDeleteAll}
|
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
|
||||||
disabled={deleteAllMutation.isPending}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteAllMutation.isPending ? "Suppression..." : "Supprimer"}
|
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -183,340 +259,108 @@ export default function History() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compteurs */}
|
{isAdmin && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
|
||||||
<Card className="border-green-200 bg-green-50">
|
<CardHeader className="pb-4">
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-600 shrink-0" />
|
<div className="flex gap-3">
|
||||||
<div>
|
<div className="rounded-lg bg-teal-600 p-2 text-white"><Mail className="h-5 w-5" /></div>
|
||||||
<p className="text-2xl font-bold text-green-700">{counts.totalImported}</p>
|
<div>
|
||||||
<p className="text-xs text-green-600 font-medium">Factures importées</p>
|
<CardTitle className="text-lg">Lecture e-mail ponctuelle</CardTitle>
|
||||||
</div>
|
<CardDescription>Déclenchez une seule vérification pour une boîte configurée, sans réactiver le traitement automatique.</CardDescription>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
<Card className="border-orange-200 bg-orange-50">
|
<Badge variant="outline" className="w-fit border-teal-200 bg-white text-teal-700">Aucune planification créée</Badge>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filtres */}
|
<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-3 items-center">
|
|
||||||
{/* Filtres statut */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{filterButtons.map((btn) => (
|
{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
|
{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>
|
||||||
key={btn.key}
|
</button>)}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden h-6 w-px bg-blue-200 sm:block" />
|
||||||
{/* Séparateur */}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="h-6 w-px bg-gray-200" />
|
|
||||||
|
|
||||||
{/* Filtre Période */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarDays className="h-4 w-4 text-gray-500" />
|
<CalendarDays className="h-4 w-4 text-gray-500" />
|
||||||
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}>
|
<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>
|
||||||
<SelectTrigger className="h-8 w-28 text-sm">
|
<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>
|
||||||
<SelectValue placeholder="Année" />
|
<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>
|
||||||
</SelectTrigger>
|
<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>
|
||||||
<SelectContent>
|
{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>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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é */}
|
<Card><CardContent className="overflow-x-auto p-0">
|
||||||
<span className="text-sm text-gray-500 ml-auto">
|
{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 ? (
|
||||||
{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} — {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}
|
<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>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</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>}
|
||||||
{/* Tableau */}
|
</CardContent></Card>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dialog visualisation détails */}
|
<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>
|
||||||
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
{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>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<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>
|
||||||
<DialogHeader>
|
{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>}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
{selectedLog.duplicateDetails && <DetailsList title="Doublons ignorés" tone="yellow" value={selectedLog.duplicateDetails} icon={<RotateCcw className="h-4 w-4" />} />}
|
||||||
<FileText className="h-5 w-5 text-blue-500" />
|
{selectedLog.errorDetails && <DetailsList title="Erreurs" tone="red" value={selectedLog.errorDetails} icon={<AlertTriangle className="h-4 w-4" />} />}
|
||||||
Détails de l'import
|
</div>}
|
||||||
</DialogTitle>
|
</DialogContent></Dialog>
|
||||||
</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>
|
|
||||||
</DashboardLayout>
|
</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,
|
"when": 1787394418464,
|
||||||
"tag": "0037_goofy_quentin_quire",
|
"tag": "0037_goofy_quentin_quire",
|
||||||
"breakpoints": true
|
"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)
|
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
|
||||||
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
|
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
|
||||||
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
|
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(),
|
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 { drizzle } from "drizzle-orm/mysql2";
|
||||||
import {
|
import {
|
||||||
InsertUser,
|
InsertUser,
|
||||||
@@ -538,6 +538,44 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
|
|||||||
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
|
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> {
|
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
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 =============
|
// ============= DEPARTMENT LIST OPERATIONS =============
|
||||||
|
|
||||||
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
|
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createImapFlowOptions, type EmailImportConfig } from "./emailImportService";
|
import {
|
||||||
|
createImapFlowOptions,
|
||||||
|
type EmailImportConfig,
|
||||||
|
validateEmailImportConfiguration,
|
||||||
|
} from "./emailImportService";
|
||||||
|
|
||||||
const baseConfig: EmailImportConfig = {
|
const baseConfig: EmailImportConfig = {
|
||||||
userId: 2,
|
userId: 2,
|
||||||
@@ -36,3 +40,39 @@ describe("createImapFlowOptions", () => {
|
|||||||
expect(options.auth).not.toHaveProperty("accessToken");
|
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;
|
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.
|
* Construit les options ImapFlow sans effectuer d'appel réseau.
|
||||||
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2.
|
* 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.
|
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
|
||||||
const activeChecks = new Map<number, Promise<void>>();
|
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);
|
const runningCheck = activeChecks.get(config.userId);
|
||||||
if (runningCheck) {
|
if (runningCheck) {
|
||||||
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
|
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
|
||||||
return runningCheck;
|
return runningCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
const check = checkEmailsForPDFs(config).finally(() => {
|
const check = checkEmailsForPDFs(config, trigger).finally(() => {
|
||||||
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
|
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
|
||||||
});
|
});
|
||||||
activeChecks.set(config.userId, check);
|
activeChecks.set(config.userId, check);
|
||||||
@@ -88,7 +125,8 @@ function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
|
|||||||
async function processEmailAttachment(
|
async function processEmailAttachment(
|
||||||
userId: number,
|
userId: number,
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
emailSubject: string
|
emailSubject: string,
|
||||||
|
trigger: EmailImportTrigger,
|
||||||
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
|
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
|
||||||
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
|
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
|
||||||
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
|
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
|
||||||
@@ -323,6 +361,7 @@ async function processEmailAttachment(
|
|||||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||||
warningMessage,
|
warningMessage,
|
||||||
importSource: "email",
|
importSource: "email",
|
||||||
|
importTrigger: trigger,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
|
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
|
* 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 imapConfig = await buildImapConfig(config);
|
||||||
const client = new ImapFlow(imapConfig);
|
const client = new ImapFlow(imapConfig);
|
||||||
client.on("error", (error) => {
|
client.on("error", (error) => {
|
||||||
@@ -450,6 +492,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
|
|||||||
config.userId,
|
config.userId,
|
||||||
attachment,
|
attachment,
|
||||||
parsed.subject || "No subject",
|
parsed.subject || "No subject",
|
||||||
|
trigger,
|
||||||
);
|
);
|
||||||
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
|
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
|
||||||
|
|
||||||
@@ -615,29 +658,28 @@ export function isEmailImportServiceRunning(userId: number): boolean {
|
|||||||
return activeIntervals.has(userId);
|
return activeIntervals.has(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function triggerConfiguredEmailCheck(
|
||||||
* Manually trigger an immediate email check for a user
|
userId: number,
|
||||||
*/
|
requireAutomaticEnabled: boolean,
|
||||||
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
// Get user's import settings
|
|
||||||
const settings = await getImportSettingsByUser(userId);
|
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é" };
|
return { success: false, message: "Import par email non activé" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.emailImportAddress || !settings.emailImportHost) {
|
const validationError = validateEmailImportConfiguration(settings);
|
||||||
return { success: false, message: "Configuration IMAP incomplète" };
|
if (validationError) return { success: false, message: validationError };
|
||||||
}
|
|
||||||
|
|
||||||
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
|
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
|
||||||
|
|
||||||
const config: EmailImportConfig = {
|
const config: EmailImportConfig = {
|
||||||
userId,
|
userId,
|
||||||
emailAddress: settings.emailImportAddress,
|
// validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
|
||||||
|
emailAddress: settings.emailImportAddress!,
|
||||||
password: settings.emailImportPassword || "",
|
password: settings.emailImportPassword || "",
|
||||||
host: settings.emailImportHost,
|
host: settings.emailImportHost!,
|
||||||
port: settings.emailImportPort || 993,
|
port: settings.emailImportPort || 993,
|
||||||
sinceDate: settings.emailImportSinceDate ?? undefined,
|
sinceDate: settings.emailImportSinceDate ?? undefined,
|
||||||
authMode,
|
authMode,
|
||||||
@@ -647,7 +689,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
|
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" };
|
return { success: true, message: "Vérification terminée avec succès" };
|
||||||
} catch (error: any) {
|
} 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
|
* Stop all email import services
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ async function processFolderFile(
|
|||||||
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
|
||||||
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
|
||||||
importSource: "folder",
|
importSource: "folder",
|
||||||
|
importTrigger: "automatic",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move file to processed folder
|
// Move file to processed folder
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ import {
|
|||||||
isInvoiceBlacklisted,
|
isInvoiceBlacklisted,
|
||||||
getAllInvoices,
|
getAllInvoices,
|
||||||
getAllImportLogs,
|
getAllImportLogs,
|
||||||
|
getAllImportLogsWithDetails,
|
||||||
getAllBapHistory,
|
getAllBapHistory,
|
||||||
createImportLog,
|
createImportLog,
|
||||||
getImportLogsByUser,
|
getImportLogsByUser,
|
||||||
|
getImportLogsWithDetailsByUser,
|
||||||
deleteAllImportLogs,
|
deleteAllImportLogs,
|
||||||
getLlmLogsBySourceFile,
|
getLlmLogsBySourceFile,
|
||||||
getLlmLogsByInvoice,
|
getLlmLogsByInvoice,
|
||||||
getImportSettingsByUser,
|
getImportSettingsByUser,
|
||||||
|
getEmailImportAccounts,
|
||||||
upsertImportSettings,
|
upsertImportSettings,
|
||||||
getDepartmentsByUser,
|
getDepartmentsByUser,
|
||||||
createDepartment,
|
createDepartment,
|
||||||
@@ -94,7 +97,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
|
|||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
import { drawBapCartouche } from "./bapCartouche";
|
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 { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { processFreeproExcel } from "./freeproService";
|
import { processFreeproExcel } from "./freeproService";
|
||||||
@@ -409,6 +412,7 @@ export const appRouter = router({
|
|||||||
duplicateDetails: JSON.stringify(duplicateDetails),
|
duplicateDetails: JSON.stringify(duplicateDetails),
|
||||||
errorDetails: JSON.stringify(errorDetails),
|
errorDetails: JSON.stringify(errorDetails),
|
||||||
importSource: "file",
|
importSource: "file",
|
||||||
|
importTrigger: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1646,9 +1650,9 @@ export const appRouter = router({
|
|||||||
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
getByUser: protectedProcedure.query(async ({ ctx }) => {
|
||||||
// Les admins voient tous les logs d'import
|
// Les admins voient tous les logs d'import
|
||||||
if (ctx.user.role === 'admin') {
|
if (ctx.user.role === 'admin') {
|
||||||
return getAllImportLogs();
|
return getAllImportLogsWithDetails();
|
||||||
}
|
}
|
||||||
return getImportLogsByUser(ctx.user.id);
|
return getImportLogsWithDetailsByUser(ctx.user.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
@@ -1715,6 +1719,18 @@ export const appRouter = router({
|
|||||||
const result = await triggerEmailCheck(ctx.user.id);
|
const result = await triggerEmailCheck(ctx.user.id);
|
||||||
return result;
|
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 =============
|
// ============= 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] 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] 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
|
- [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