Checkpoint: Application complète : deux tableaux de bord (Veille Stratégique + AAP), import Excel quotidien avec déduplication, sources multiples (local/OneDrive/FTP/SharePoint), affichage liste/vignettes, filtres multi-critères, gestion utilisateurs, logs d'import, page paramètres, authentification locale, tâche cron 06h00, 13 tests Vitest passants.
This commit is contained in:
192
client/src/pages/ImportLogs.tsx
Normal file
192
client/src/pages/ImportLogs.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImportLog {
|
||||
id: number;
|
||||
fileType: "veille" | "aap";
|
||||
status: "success" | "error" | "partial";
|
||||
newRows: number | null;
|
||||
skippedRows: number | null;
|
||||
totalRows: number | null;
|
||||
errorMessage: string | null;
|
||||
startedAt: Date;
|
||||
completedAt: Date | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
success: { label: "Succès", color: "bg-emerald-100 text-emerald-800 border-emerald-200", icon: <CheckCircle size={12} /> },
|
||||
error: { label: "Erreur", color: "bg-red-100 text-red-800 border-red-200", icon: <XCircle size={12} /> },
|
||||
partial: { label: "Partiel", color: "bg-amber-100 text-amber-800 border-amber-200", icon: <Activity size={12} /> },
|
||||
};
|
||||
|
||||
const FILE_CONFIG = {
|
||||
veille: { label: "Veille Stratégique", color: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||
aap: { label: "Appels à Projets", color: "bg-violet-100 text-violet-800 border-violet-200" },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ImportLogs() {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const logsQuery = trpc.import.logs.useQuery({ page, pageSize: PAGE_SIZE });
|
||||
const importMutation = trpc.import.run.useMutation({
|
||||
onSuccess: () => logsQuery.refetch(),
|
||||
});
|
||||
|
||||
const logs = (logsQuery.data?.logs ?? []) as unknown as ImportLog[];
|
||||
const total = logsQuery.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-up">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity size={22} className="text-primary" />
|
||||
<h1 className="text-2xl font-bold text-foreground">Logs d'import</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Historique des imports automatiques et manuels</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate({ type: "all" })}
|
||||
disabled={importMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{importMutation.isPending ? <Loader2 size={15} className="animate-spin" /> : <RefreshCw size={15} />}
|
||||
Importer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
{logsQuery.data?.stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total imports", value: logsQuery.data.stats.total, icon: <FileText size={16} />, color: "text-primary" },
|
||||
{ label: "Succès", value: logsQuery.data.stats.success, icon: <CheckCircle size={16} />, color: "text-emerald-600" },
|
||||
{ label: "Erreurs", value: logsQuery.data.stats.errors, icon: <XCircle size={16} />, color: "text-red-500" },
|
||||
{ label: "Nouvelles entrées", value: logsQuery.data.stats.totalNewRows, icon: <Activity size={16} />, color: "text-accent" },
|
||||
].map((stat) => (
|
||||
<Card key={stat.label} className="border-border/50">
|
||||
<CardContent className="p-4">
|
||||
<div className={cn("flex items-center gap-2 mb-1", stat.color)}>
|
||||
{stat.icon}
|
||||
<span className="text-xs font-medium text-muted-foreground">{stat.label}</span>
|
||||
</div>
|
||||
<p className={cn("text-2xl font-bold", stat.color)}>{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tableau des logs */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{logsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Activity size={40} className="text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">Aucun log d'import</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Les imports apparaîtront ici</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Date</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-36">Fichier</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Statut</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Nouvelles</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Ignorées</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Total</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Durée</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{logs.map((log) => {
|
||||
const status = STATUS_CONFIG[log.status] || STATUS_CONFIG.error;
|
||||
const fileConf = FILE_CONFIG[log.fileType] || FILE_CONFIG.veille;
|
||||
return (
|
||||
<tr key={log.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={11} />
|
||||
{format(new Date(log.startedAt), "d MMM yyyy HH:mm", { locale: fr })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={cn("text-xs", fileConf.color)}>
|
||||
{fileConf.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={cn("text-xs gap-1", status.color)}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-semibold text-emerald-600">+{log.newRows}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{log.skippedRows}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{log.totalRows}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{log.startedAt && log.completedAt
|
||||
? `${((new Date(log.completedAt).getTime() - new Date(log.startedAt).getTime()) / 1000).toFixed(1)}s`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs">
|
||||
{log.errorMessage ? (
|
||||
<span className="text-red-500">{log.errorMessage}</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user