Checkpoint: Ajout des zones de téléversement drag & drop dans la page Paramètres lorsque la source "Fichier local" est sélectionnée, avec résultat d'import affiché immédiatement

This commit is contained in:
Manus
2026-03-16 14:45:02 -04:00
parent 3ae37760a3
commit b7aa274921
2 changed files with 245 additions and 40 deletions

View File

@@ -1,13 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
HardDrive, HardDrive,
@@ -17,9 +16,13 @@ import {
Save, Save,
RefreshCw, RefreshCw,
CheckCircle, CheckCircle,
XCircle,
Clock, Clock,
Loader2, Loader2,
Info, Info,
Upload,
FileSpreadsheet,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -40,6 +43,149 @@ const SOURCE_LABELS: Record<SourceType, string> = {
sharepoint: "SharePoint", sharepoint: "SharePoint",
}; };
// ─── Composant UploadZone (réutilisé depuis ImportLogs) ───────────────────────
interface UploadResult {
success: boolean;
fileType: "veille" | "aap";
fileName: string;
totalRows: number;
newRows: number;
skippedRows: number;
errors: string[];
status: "success" | "partial" | "error";
error?: string;
}
function UploadZone({
fileType,
label,
accentColor,
onSuccess,
}: {
fileType: "veille" | "aap";
label: string;
accentColor: 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-2">
<div
className={cn(
"relative border-2 border-dashed rounded-xl p-5 text-center cursor-pointer transition-all duration-200",
isDragging ? "border-primary bg-primary/5 scale-[1.01]" : "border-border hover:border-primary/40 hover:bg-muted/20",
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 py-2">
<Loader2 size={24} className="animate-spin text-primary" />
<p className="text-xs text-muted-foreground">Import en cours</p>
</div>
) : (
<div className="flex items-center gap-3 justify-center">
<div className={cn("w-9 h-9 rounded-lg flex items-center justify-center shrink-0", accentColor)}>
<FileSpreadsheet size={18} />
</div>
<div className="text-left">
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-xs text-muted-foreground">
Glissez-déposez ou <span className="text-primary underline cursor-pointer">parcourez</span> · .xlsx / .xls
</p>
</div>
<Upload size={16} className="text-muted-foreground ml-auto shrink-0" />
</div>
)}
</div>
{/* Résultat */}
{lastResult && (
<div className={cn(
"rounded-lg border px-3 py-2 text-xs flex items-start gap-2",
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={13} className="mt-0.5 shrink-0" /> :
lastResult.status === "partial" ? <AlertCircle size={13} className="mt-0.5 shrink-0" /> :
<XCircle size={13} className="mt-0.5 shrink-0" />}
<div>
<span className="font-medium">{lastResult.fileName}</span>
<span className="ml-2 opacity-80">
+{lastResult.newRows} nouvelle(s) · {lastResult.skippedRows} ignorée(s) · {lastResult.totalRows} total
</span>
{lastResult.errors.length > 0 && (
<p className="mt-0.5 opacity-70">{lastResult.errors[0]}</p>
)}
</div>
</div>
)}
</div>
);
}
// ─── Page principale ──────────────────────────────────────────────────────────
export default function SettingsPage() { export default function SettingsPage() {
const settingsQuery = trpc.settings.get.useQuery(); const settingsQuery = trpc.settings.get.useQuery();
const saveMutation = trpc.settings.save.useMutation({ const saveMutation = trpc.settings.save.useMutation({
@@ -83,11 +229,7 @@ export default function SettingsPage() {
}, [settingsQuery.data]); }, [settingsQuery.data]);
const set = (key: string, value: string) => setForm((prev) => ({ ...prev, [key]: value })); const set = (key: string, value: string) => setForm((prev) => ({ ...prev, [key]: value }));
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
const handleSave = () => {
saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
};
const sourceType = (form.source_type || "local") as SourceType; const sourceType = (form.source_type || "local") as SourceType;
return ( return (
@@ -116,6 +258,8 @@ export default function SettingsPage() {
{/* ─── Source des fichiers ─────────────────────────────────────── */} {/* ─── Source des fichiers ─────────────────────────────────────── */}
<TabsContent value="source" className="space-y-4 mt-4"> <TabsContent value="source" className="space-y-4 mt-4">
{/* Sélecteur de type de source */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Type de source</CardTitle> <CardTitle className="text-base">Type de source</CardTitle>
@@ -142,14 +286,74 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Chemins des fichiers */} {/* ── SOURCE LOCALE : upload direct ───────────────────────────── */}
{sourceType === "local" && (
<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={16} className="text-primary" />
Import manuel par téléversement
</CardTitle>
<CardDescription>
Déposez vos fichiers Excel directement depuis votre ordinateur.
Les nouvelles entrées sont ajoutées immédiatement, les doublons ignorés.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<UploadZone
fileType="veille"
label="Veille Stratégique"
accentColor="bg-blue-100 text-blue-700"
/>
<UploadZone
fileType="aap"
label="Appels à Projets"
accentColor="bg-violet-100 text-violet-700"
/>
</CardContent>
</Card>
)}
{/* ── SOURCE LOCALE : chemin serveur (optionnel pour cron) ──── */}
{sourceType === "local" && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<HardDrive size={15} />
Chemin serveur (import automatique)
</CardTitle>
<CardDescription>
Optionnel — renseignez un chemin absolu sur le serveur pour l'import quotidien automatique
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Fichier Veille Stratégique</Label>
<Input
placeholder="/data/veille/Veillestratégique.xlsx"
value={form.veille_file_path}
onChange={(e) => set("veille_file_path", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Fichier Appels à Projets</Label>
<Input
placeholder="/data/veille/AAP2étendu.xlsx"
value={form.aap_file_path}
onChange={(e) => set("aap_file_path", e.target.value)}
/>
</div>
</CardContent>
</Card>
)}
{/* ── SOURCES DISTANTES : chemins ─────────────────────────────── */}
{sourceType !== "local" && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Chemins des fichiers</CardTitle> <CardTitle className="text-base">Chemins des fichiers</CardTitle>
<CardDescription> <CardDescription>
{sourceType === "local" {sourceType === "ftp"
? "Chemin absolu vers les fichiers sur le serveur"
: sourceType === "ftp"
? "Chemin relatif sur le serveur FTP" ? "Chemin relatif sur le serveur FTP"
: "URL complète vers les fichiers"} : "URL complète vers les fichiers"}
</CardDescription> </CardDescription>
@@ -158,7 +362,7 @@ export default function SettingsPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Fichier Veille Stratégique</Label> <Label>Fichier Veille Stratégique</Label>
<Input <Input
placeholder={sourceType === "local" ? "/data/veille/Veillestratégique.xlsx" : "https://..."} placeholder="https://..."
value={form.veille_file_path} value={form.veille_file_path}
onChange={(e) => set("veille_file_path", e.target.value)} onChange={(e) => set("veille_file_path", e.target.value)}
/> />
@@ -166,13 +370,14 @@ export default function SettingsPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Fichier Appels à Projets</Label> <Label>Fichier Appels à Projets</Label>
<Input <Input
placeholder={sourceType === "local" ? "/data/veille/AAP2étendu.xlsx" : "https://..."} placeholder="https://..."
value={form.aap_file_path} value={form.aap_file_path}
onChange={(e) => set("aap_file_path", e.target.value)} onChange={(e) => set("aap_file_path", e.target.value)}
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Configuration FTP */} {/* Configuration FTP */}
{sourceType === "ftp" && ( {sourceType === "ftp" && (
@@ -264,7 +469,6 @@ export default function SettingsPage() {
type="time" type="time"
value={form.import_time || "06:00"} value={form.import_time || "06:00"}
onChange={(e) => set("import_time", e.target.value)} onChange={(e) => set("import_time", e.target.value)}
className="h-11"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Prochain import : demain à {form.import_time || "06:00"} Prochain import : demain à {form.import_time || "06:00"}
@@ -275,8 +479,8 @@ export default function SettingsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"><RefreshCw size={16} />Import manuel</CardTitle> <CardTitle className="text-base flex items-center gap-2"><RefreshCw size={16} />Import manuel depuis chemin configuré</CardTitle>
<CardDescription>Déclenchez un import immédiat des deux fichiers</CardDescription> <CardDescription>Déclenchez un import immédiat depuis la source configurée (chemin serveur, FTP, OneDrive, SharePoint)</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">

View File

@@ -37,3 +37,4 @@
- [x] Frontend : bouton "Importer un fichier" dans la page Logs d'import avec sélecteur veille/AAP - [x] Frontend : bouton "Importer un fichier" dans la page Logs d'import avec sélecteur veille/AAP
- [x] Frontend : zone de dépôt (drag & drop) dans la page Paramètres pour les deux fichiers - [x] Frontend : zone de dépôt (drag & drop) dans la page Paramètres pour les deux fichiers
- [x] Frontend : afficher le résultat de l'import (nouvelles entrées, erreurs) après upload - [x] Frontend : afficher le résultat de l'import (nouvelles entrées, erreurs) après upload
- [x] Page Paramètres : afficher les zones d'upload (drag & drop) quand la source "local" est sélectionnée