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:
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
HardDrive,
|
||||
@@ -17,9 +16,13 @@ import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Info,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -40,6 +43,149 @@ const SOURCE_LABELS: Record<SourceType, string> = {
|
||||
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() {
|
||||
const settingsQuery = trpc.settings.get.useQuery();
|
||||
const saveMutation = trpc.settings.save.useMutation({
|
||||
@@ -83,11 +229,7 @@ export default function SettingsPage() {
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -116,6 +258,8 @@ export default function SettingsPage() {
|
||||
|
||||
{/* ─── Source des fichiers ─────────────────────────────────────── */}
|
||||
<TabsContent value="source" className="space-y-4 mt-4">
|
||||
|
||||
{/* Sélecteur de type de source */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Type de source</CardTitle>
|
||||
@@ -142,37 +286,98 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chemins des fichiers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Chemins des fichiers</CardTitle>
|
||||
<CardDescription>
|
||||
{sourceType === "local"
|
||||
? "Chemin absolu vers les fichiers sur le serveur"
|
||||
: sourceType === "ftp"
|
||||
? "Chemin relatif sur le serveur FTP"
|
||||
: "URL complète vers les fichiers"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Fichier Veille Stratégique</Label>
|
||||
<Input
|
||||
placeholder={sourceType === "local" ? "/data/veille/Veillestratégique.xlsx" : "https://..."}
|
||||
value={form.veille_file_path}
|
||||
onChange={(e) => set("veille_file_path", e.target.value)}
|
||||
{/* ── 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fichier Appels à Projets</Label>
|
||||
<Input
|
||||
placeholder={sourceType === "local" ? "/data/veille/AAP2étendu.xlsx" : "https://..."}
|
||||
value={form.aap_file_path}
|
||||
onChange={(e) => set("aap_file_path", e.target.value)}
|
||||
<UploadZone
|
||||
fileType="aap"
|
||||
label="Appels à Projets"
|
||||
accentColor="bg-violet-100 text-violet-700"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Chemins des fichiers</CardTitle>
|
||||
<CardDescription>
|
||||
{sourceType === "ftp"
|
||||
? "Chemin relatif sur le serveur FTP"
|
||||
: "URL complète vers les fichiers"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Fichier Veille Stratégique</Label>
|
||||
<Input
|
||||
placeholder="https://..."
|
||||
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="https://..."
|
||||
value={form.aap_file_path}
|
||||
onChange={(e) => set("aap_file_path", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration FTP */}
|
||||
{sourceType === "ftp" && (
|
||||
@@ -264,7 +469,6 @@ export default function SettingsPage() {
|
||||
type="time"
|
||||
value={form.import_time || "06:00"}
|
||||
onChange={(e) => set("import_time", e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prochain import : demain à {form.import_time || "06:00"}
|
||||
@@ -275,8 +479,8 @@ export default function SettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2"><RefreshCw size={16} />Import manuel</CardTitle>
|
||||
<CardDescription>Déclenchez un import immédiat des deux fichiers</CardDescription>
|
||||
<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 depuis la source configurée (chemin serveur, FTP, OneDrive, SharePoint)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
Reference in New Issue
Block a user