Checkpoint: Unification de l'ergonomie de chargement de fichiers dans toute l'application : le composant FileUpload utilise maintenant la même interface élégante que la boîte de dialogue d'attestations. Affichage immédiat du nom de fichier, animation de chargement avec barre de progression, feedback visuel cohérent (gris → bleu → vert). Cette amélioration s'applique automatiquement à toutes les pièces jointes des rappels (2 par rappel). Déployé sur le VPS de production.

This commit is contained in:
Manus
2026-02-02 06:13:58 -05:00
parent a77123e07f
commit 014c2cd68b
2 changed files with 132 additions and 60 deletions

View File

@@ -1,8 +1,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { X, Upload, FileText } from "lucide-react"; import { X, Upload, FileText } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
interface FileUploadProps { interface FileUploadProps {
value?: { value?: {
@@ -26,31 +26,52 @@ interface FileUploadProps {
export function FileUpload({ value, onChange, accept, maxSize = 10, label = "Pièce jointe (optionnel)" }: FileUploadProps) { export function FileUpload({ value, onChange, accept, maxSize = 10, label = "Pièce jointe (optionnel)" }: FileUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [uploadProgress, setUploadProgress] = useState(0);
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
// Afficher IMMÉDIATEMENT le nom du fichier sélectionné
setSelectedFileName(file.name);
// Vérifier la taille // Vérifier la taille
const fileSizeMB = file.size / (1024 * 1024); const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) { if (fileSizeMB > maxSize) {
setError(`Le fichier est trop volumineux (max ${maxSize} Mo)`); toast.error(`Le fichier est trop volumineux (max ${maxSize} Mo)`);
setSelectedFileName(null);
e.target.value = '';
return; return;
} }
setError(null); // Afficher immédiatement l'état de chargement
setUploading(true); setUploading(true);
setUploadProgress(0);
// Utiliser requestAnimationFrame pour garantir que le navigateur
// affiche l'animation AVANT de bloquer sur le fetch
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 90) return prev;
return prev + 10;
});
}, 200);
const response = await fetch("/api/upload-file", { const response = await fetch("/api/upload-file", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) { if (!response.ok) {
throw new Error("Erreur lors de l'upload"); throw new Error("Erreur lors de l'upload");
} }
@@ -64,17 +85,23 @@ export function FileUpload({ value, onChange, accept, maxSize = 10, label = "Pi
typeFichier: file.type, typeFichier: file.type,
tailleFichier: file.size, tailleFichier: file.size,
}); });
toast.success("Fichier uploadé avec succès");
} catch (err) { } catch (err) {
console.error("Erreur upload:", err); console.error("Erreur upload:", err);
setError("Erreur lors de l'upload du fichier"); toast.error("Erreur lors de l'upload du fichier");
setUploadProgress(0);
} finally { } finally {
setUploading(false); setUploading(false);
setSelectedFileName(null);
} }
});
});
}; };
const handleRemove = () => { const handleRemove = () => {
onChange(null); onChange(null);
setError(null); setSelectedFileName(null);
}; };
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
@@ -87,37 +114,80 @@ export function FileUpload({ value, onChange, accept, maxSize = 10, label = "Pi
<div className="space-y-2"> <div className="space-y-2">
<Label>{label}</Label> <Label>{label}</Label>
{!value ? ( {!value && !uploading ? (
<div className="space-y-2"> <label className="block">
<div className="flex items-center gap-2"> <div className="relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300 ease-in-out border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50">
<Input <div className="space-y-3">
type="file" <div className="mx-auto w-14 h-14 bg-gradient-to-br from-blue-100 to-purple-100 rounded-full flex items-center justify-center">
onChange={handleFileChange} <FileText className="w-7 h-7 text-blue-600" />
accept={accept}
disabled={uploading}
className="flex-1"
/>
{uploading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
Upload en cours...
</div> </div>
)} <div>
</div> <p className="text-sm font-semibold text-gray-700 mb-1">
{error && ( Cliquez pour choisir un fichier
<p className="text-sm text-destructive">{error}</p> </p>
)} <p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground"> ou glissez-déposez votre fichier ici
Taille maximale : {maxSize} Mo
{accept && ` • Formats acceptés : ${accept}`}
</p> </p>
</div> </div>
<div className="flex items-center justify-center gap-2 text-xs text-gray-400">
{accept && (
<span className="px-2 py-1 bg-white rounded-full border border-gray-200">
{accept}
</span>
)}
<span className="px-2 py-1 bg-white rounded-full border border-gray-200">
Max {maxSize} Mo
</span>
</div>
</div>
</div>
<input
type="file"
accept={accept}
className="hidden"
disabled={uploading}
onChange={handleFileChange}
/>
</label>
) : uploading ? (
<div className="relative border-2 border-dashed rounded-xl p-6 text-center border-blue-400 bg-blue-50">
<div className="space-y-3">
<div className="mx-auto w-14 h-14 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center animate-pulse">
<Upload className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-blue-600 mb-2">
Upload en cours...
</p>
{selectedFileName && (
<p className="text-xs text-gray-600 font-medium mb-3 px-4 break-all">
{selectedFileName}
</p>
)}
<div className="w-full max-w-xs mx-auto">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span className="font-medium">Progression</span>
<span className="font-bold text-blue-600">{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden shadow-inner">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-600 rounded-full transition-all duration-300 ease-out shadow-lg"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
</div>
</div>
</div>
) : ( ) : (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/50"> <div className="flex items-center gap-3 p-4 border-2 rounded-xl bg-green-50 border-green-400">
<FileText className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{value.nomFichier}</p> <p className="text-sm font-semibold text-green-600 mb-1"> Fichier prêt</p>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-gray-700 font-medium truncate">{value.nomFichier}</p>
<p className="text-xs text-gray-500 mt-1">
{formatFileSize(value.tailleFichier)} {formatFileSize(value.tailleFichier)}
</p> </p>
</div> </div>
@@ -126,9 +196,9 @@ export function FileUpload({ value, onChange, accept, maxSize = 10, label = "Pi
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleRemove} onClick={handleRemove}
className="flex-shrink-0" className="flex-shrink-0 text-gray-500 hover:text-red-600 hover:bg-red-50"
> >
<X className="h-4 w-4" /> <X className="h-5 w-5" />
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1480,3 +1480,5 @@
- [x] Corriger la latence avec requestAnimationFrame pour affichage avant fetch bloquant - [x] Corriger la latence avec requestAnimationFrame pour affichage avant fetch bloquant
- [x] Afficher immédiatement le nom du fichier sélectionné dans la boîte de dialogue - [x] Afficher immédiatement le nom du fichier sélectionné dans la boîte de dialogue
- [x] Appliquer la même ergonomie de boîte de dialogue pour les pièces jointes des rappels