Checkpoint: Ajout de l'upload direct de fichiers Excel depuis le navigateur (drag & drop), nettoyage automatique des balises HTML dans les résumés, import fonctionnel avec 38 entrées Veille + 7 AAP

This commit is contained in:
Manus
2026-03-16 14:35:21 -04:00
parent 8fb71e8bda
commit 3ae37760a3
9 changed files with 756 additions and 11 deletions

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
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 } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Activity,
CheckCircle,
@@ -13,10 +13,14 @@ import {
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;
@@ -31,6 +35,18 @@ interface ImportLog {
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} /> },
@@ -44,6 +60,150 @@ const FILE_CONFIG = {
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);
@@ -56,6 +216,10 @@ export default function ImportLogs() {
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 */}
@@ -70,13 +234,43 @@ export default function ImportLogs() {
<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} />}
Importer maintenant
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">
@@ -124,7 +318,7 @@ export default function ImportLogs() {
<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>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Source</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -151,19 +345,19 @@ export default function ImportLogs() {
</Badge>
</td>
<td className="px-4 py-3">
<span className="font-semibold text-emerald-600">+{log.newRows}</span>
<span className="font-semibold text-emerald-600">+{log.newRows ?? 0}</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-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">
{log.errorMessage ? (
<span className="text-red-500">{log.errorMessage}</span>
) : "—"}
<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>
);