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 { 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">
|
||||||
|
|||||||
1
todo.md
1
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user