387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
import { useState, useRef, useCallback } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Activity,
|
|
CheckCircle,
|
|
XCircle,
|
|
Loader2,
|
|
RefreshCw,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Clock,
|
|
FileText,
|
|
Upload,
|
|
FileSpreadsheet,
|
|
AlertCircle,
|
|
} from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
|
|
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;
|
|
}
|
|
|
|
interface UploadResult {
|
|
success: boolean;
|
|
fileType: "veille" | "aap";
|
|
fileName: string;
|
|
totalRows: number;
|
|
newRows: number;
|
|
skippedRows: number;
|
|
errors: string[];
|
|
status: "success" | "partial" | "error";
|
|
error?: string;
|
|
}
|
|
|
|
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;
|
|
|
|
// ─── Composant UploadZone ─────────────────────────────────────────────────────
|
|
|
|
function UploadZone({
|
|
fileType,
|
|
label,
|
|
color,
|
|
onSuccess,
|
|
}: {
|
|
fileType: "veille" | "aap";
|
|
label: string;
|
|
color: string;
|
|
onSuccess: () => void;
|
|
}) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [lastResult, setLastResult] = useState<UploadResult | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleFile = useCallback(
|
|
async (file: File) => {
|
|
if (!file) return;
|
|
if (!file.name.endsWith(".xlsx") && !file.name.endsWith(".xls")) {
|
|
toast.error("Seuls les fichiers Excel (.xlsx, .xls) sont acceptés");
|
|
return;
|
|
}
|
|
|
|
setIsUploading(true);
|
|
setLastResult(null);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
formData.append("fileType", fileType);
|
|
|
|
const res = await fetch("/api/upload-excel", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
const data: UploadResult = await res.json();
|
|
|
|
if (!res.ok || !data.success) {
|
|
throw new Error(data.error || "Erreur lors de l'import");
|
|
}
|
|
|
|
setLastResult(data);
|
|
onSuccess();
|
|
|
|
if (data.newRows > 0) {
|
|
toast.success(`Import réussi — ${data.newRows} nouvelle(s) entrée(s) ajoutée(s)`);
|
|
} else {
|
|
toast.info(`Import terminé — aucune nouvelle entrée (${data.skippedRows} déjà présentes)`);
|
|
}
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
toast.error(`Erreur : ${msg}`);
|
|
setLastResult({ success: false, fileType, fileName: file.name, totalRows: 0, newRows: 0, skippedRows: 0, errors: [msg], status: "error", error: msg });
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
},
|
|
[fileType, onSuccess]
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFile(file);
|
|
},
|
|
[handleFile]
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div
|
|
className={cn(
|
|
"relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200",
|
|
isDragging ? "border-primary bg-primary/5 scale-[1.01]" : "border-border hover:border-primary/50 hover:bg-muted/30",
|
|
isUploading && "pointer-events-none opacity-60"
|
|
)}
|
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={onDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls"
|
|
className="hidden"
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ""; }}
|
|
/>
|
|
|
|
{isUploading ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Loader2 size={28} className="animate-spin text-primary" />
|
|
<p className="text-sm text-muted-foreground">Import en cours…</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className={cn("w-10 h-10 rounded-lg flex items-center justify-center", color)}>
|
|
<FileSpreadsheet size={20} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Glissez-déposez ou <span className="text-primary underline">parcourez</span>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/60 mt-0.5">.xlsx ou .xls · max 50 MB</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Résultat du dernier upload */}
|
|
{lastResult && (
|
|
<div className={cn(
|
|
"rounded-lg border px-4 py-3 text-sm flex items-start gap-3",
|
|
lastResult.status === "success" ? "bg-emerald-50 border-emerald-200 text-emerald-800" :
|
|
lastResult.status === "partial" ? "bg-amber-50 border-amber-200 text-amber-800" :
|
|
"bg-red-50 border-red-200 text-red-800"
|
|
)}>
|
|
{lastResult.status === "success" ? <CheckCircle size={16} className="mt-0.5 shrink-0" /> :
|
|
lastResult.status === "partial" ? <AlertCircle size={16} className="mt-0.5 shrink-0" /> :
|
|
<XCircle size={16} className="mt-0.5 shrink-0" />}
|
|
<div>
|
|
<p className="font-medium">{lastResult.fileName}</p>
|
|
<p className="text-xs mt-0.5">
|
|
{lastResult.newRows} nouvelle(s) · {lastResult.skippedRows} ignorée(s) · {lastResult.totalRows} total
|
|
</p>
|
|
{lastResult.errors.length > 0 && (
|
|
<p className="text-xs mt-1 opacity-80">{lastResult.errors[0]}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Page principale ──────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
|
|
const handleUploadSuccess = () => {
|
|
logsQuery.refetch();
|
|
};
|
|
|
|
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}
|
|
variant="outline"
|
|
className="gap-2"
|
|
>
|
|
{importMutation.isPending ? <Loader2 size={15} className="animate-spin" /> : <RefreshCw size={15} />}
|
|
Import depuis chemin configuré
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Upload direct */}
|
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/3 to-transparent">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Upload size={18} className="text-primary" />
|
|
Import direct depuis votre ordinateur
|
|
</CardTitle>
|
|
<p className="text-xs text-muted-foreground">
|
|
Déposez vos fichiers Excel directement — les nouvelles entrées seront ajoutées immédiatement
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<UploadZone
|
|
fileType="veille"
|
|
label="Veille Stratégique"
|
|
color="bg-blue-100 text-blue-700"
|
|
onSuccess={handleUploadSuccess}
|
|
/>
|
|
<UploadZone
|
|
fileType="aap"
|
|
label="Appels à Projets"
|
|
color="bg-violet-100 text-violet-700"
|
|
onSuccess={handleUploadSuccess}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 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">Source</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 ?? 0}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">{log.skippedRows ?? 0}</td>
|
|
<td className="px-4 py-3 text-muted-foreground">{log.totalRows ?? 0}</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 truncate">
|
|
{log.source
|
|
? log.source.split(/[\\/]/).pop() || log.source
|
|
: "—"}
|
|
</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>
|
|
);
|
|
}
|