Checkpoint: Ajout de la fonctionnalité d'import Excel pour créer des formations et séquences en masse. Inclut le template Excel, la logique backend de parsing et validation, l'interface frontend avec prévisualisation, et la documentation utilisateur complète.
This commit is contained in:
BIN
client/public/template_import_formations.xlsx
Normal file
BIN
client/public/template_import_formations.xlsx
Normal file
Binary file not shown.
@@ -20,6 +20,7 @@ import AdminCalendrier from "./pages/AdminCalendrier";
|
||||
import AdminRappels from "./pages/AdminRappels";
|
||||
import AdminAnalytics from "./pages/AdminAnalytics";
|
||||
import AdminEtablissements from "./pages/AdminEtablissements";
|
||||
import AdminImportExcel from "./pages/AdminImportExcel";
|
||||
import Inscription from "./pages/Inscription";
|
||||
import Login from "./pages/Login";
|
||||
|
||||
@@ -44,6 +45,7 @@ function Router() {
|
||||
<Route path={"/admin/rappels"} component={AdminRappels} />
|
||||
<Route path={"/admin/analytics"} component={AdminAnalytics} />
|
||||
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
|
||||
<Route path={"/admin/import-excel"} component={AdminImportExcel} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2 } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
@@ -41,6 +41,7 @@ const menuSections = [
|
||||
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
|
||||
{ icon: CalendarDays, label: "Calendrier", path: "/admin/calendrier" },
|
||||
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
||||
{ icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
330
client/src/pages/AdminImportExcel.tsx
Normal file
330
client/src/pages/AdminImportExcel.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminImportExcel() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<any>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const previewMutation = trpc.formations.previewExcel.useMutation();
|
||||
const importMutation = trpc.formations.importExcel.useMutation();
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
|
||||
toast.error("Format de fichier invalide. Veuillez sélectionner un fichier Excel (.xlsx ou .xls)");
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setPreview(null);
|
||||
|
||||
// Lire le fichier et générer la prévisualisation
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const base64 = event.target?.result as string;
|
||||
const fileBase64 = base64.split(',')[1]; // Enlever le préfixe data:...;base64,
|
||||
|
||||
const result = await previewMutation.mutateAsync({ fileBase64 });
|
||||
setPreview(result);
|
||||
|
||||
if (result.validation.errors.length > 0) {
|
||||
toast.error(`${result.validation.errors.length} erreur(s) détectée(s) dans le fichier`);
|
||||
} else {
|
||||
toast.success("Fichier valide ! Vous pouvez procéder à l'import.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Erreur lors de la lecture du fichier: ${error.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const base64 = event.target?.result as string;
|
||||
const fileBase64 = base64.split(',')[1];
|
||||
|
||||
const result = await importMutation.mutateAsync({ fileBase64 });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
`Import réussi ! ${result.formationsCreated} formation(s) et ${result.sequencesCreated} séquence(s) créée(s).`
|
||||
);
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
// Réinitialiser l'input file
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
} else {
|
||||
toast.error(`Import échoué: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
setImporting(false);
|
||||
} catch (error: any) {
|
||||
toast.error(`Erreur lors de l'import: ${error.message}`);
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error: any) {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
// Télécharger le template depuis /public
|
||||
const link = document.createElement('a');
|
||||
link.href = '/template_import_formations.xlsx';
|
||||
link.download = 'template_import_formations.xlsx';
|
||||
link.click();
|
||||
toast.success("Template téléchargé !");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Import Excel - Formations et Séquences</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importez plusieurs formations et séquences en une seule fois à partir d'un fichier Excel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Instructions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Suivez ces étapes pour importer vos données
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Téléchargez le template Excel</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Le template contient deux feuilles : "Formations" et "Séquences" avec des exemples
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Télécharger le template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Remplissez le fichier Excel</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Suivez les instructions dans le fichier. Les colonnes obligatoires sont marquées.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Importez le fichier</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sélectionnez votre fichier ci-dessous. Une prévisualisation s'affichera avant l'import.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upload */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Sélectionner un fichier Excel</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<label htmlFor="file-input">
|
||||
<Button variant="outline" asChild>
|
||||
<span>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Choisir un fichier
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
{file && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prévisualisation */}
|
||||
{preview && (
|
||||
<div className="space-y-6">
|
||||
{/* Erreurs et Avertissements */}
|
||||
{preview.validation.errors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-2">Erreurs détectées :</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{preview.validation.errors.map((error: string, index: number) => (
|
||||
<li key={index} className="text-sm">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{preview.validation.warnings.length > 0 && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-semibold mb-2">Avertissements :</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{preview.validation.warnings.map((warning: string, index: number) => (
|
||||
<li key={index} className="text-sm">{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Formations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
Formations ({preview.formations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{preview.formations.map((formation: any, index: number) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold">{formation.nom}</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formation.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{formation.publicCible}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Séquences */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
Séquences ({preview.sequences.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{preview.sequences.map((sequence: any, index: number) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold">{sequence.nom}</h4>
|
||||
<p className="text-sm text-muted-foreground">Formation: {sequence.formationNom}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{sequence.statut}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Lieu:</span> {sequence.lieu}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capacité:</span> {sequence.capaciteMax}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Public:</span> {sequence.publicCible}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Dates:</span> {sequence.dates.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bouton d'import */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={preview.validation.errors.length > 0 || importing}
|
||||
>
|
||||
{importing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{importing ? "Import en cours..." : "Importer les données"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user