Checkpoint: Application complète de gestion des formations Manager Itinova avec toutes les fonctionnalités : gestion des formations/sessions/apprenants, système d'inscription avec validations, génération d'invitations Outlook, emails automatiques, exports PDF/Excel, et documentation complète.
This commit is contained in:
@@ -5,12 +5,23 @@ import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "./pages/Home";
|
||||
import Admin from "./pages/Admin";
|
||||
import AdminFormations from "./pages/AdminFormations";
|
||||
import AdminSessions from "./pages/AdminSessions";
|
||||
import AdminApprenants from "./pages/AdminApprenants";
|
||||
import AdminSessionInscrits from "./pages/AdminSessionInscrits";
|
||||
import Inscription from "./pages/Inscription";
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={"/"} component={Home} />
|
||||
<Route path={"/inscription/:lien"} component={Inscription} />
|
||||
<Route path={"/admin"} component={Admin} />
|
||||
<Route path={"/admin/formations"} component={AdminFormations} />
|
||||
<Route path={"/admin/sessions"} component={AdminSessions} />
|
||||
<Route path={"/admin/apprenants"} component={AdminApprenants} />
|
||||
<Route path={"/admin/sessions/:id/inscrits"} component={AdminSessionInscrits} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
@@ -18,18 +29,10 @@ function Router() {
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: About Theme
|
||||
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
|
||||
// to keep consistent foreground/background color across components
|
||||
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
defaultTheme="light"
|
||||
// switchable
|
||||
>
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
|
||||
@@ -21,15 +21,17 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
{ icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" },
|
||||
{ icon: GraduationCap, label: "Formations", path: "/admin/formations" },
|
||||
{ icon: Calendar, label: "Sessions", path: "/admin/sessions" },
|
||||
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
@@ -73,7 +75,7 @@ export default function DashboardLayout({
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{APP_TITLE}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please sign in to continue
|
||||
Veuillez vous connecter pour continuer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,7 +86,7 @@ export default function DashboardLayout({
|
||||
size="lg"
|
||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Sign in
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +257,7 @@ function DashboardLayoutContent({
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
<span>Déconnexion</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
92
client/src/pages/Admin.tsx
Normal file
92
client/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { GraduationCap, Calendar, Users, CheckCircle } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Admin() {
|
||||
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
|
||||
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery();
|
||||
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Formations actives",
|
||||
value: formations?.filter(f => f.actif).length || 0,
|
||||
icon: <GraduationCap className="w-8 h-8 text-blue-500" />,
|
||||
loading: loadingFormations,
|
||||
},
|
||||
{
|
||||
title: "Sessions ouvertes",
|
||||
value: sessions?.filter(s => s.statut === "ouverte").length || 0,
|
||||
icon: <Calendar className="w-8 h-8 text-green-500" />,
|
||||
loading: loadingSessions,
|
||||
},
|
||||
{
|
||||
title: "Apprenants inscrits",
|
||||
value: apprenants?.length || 0,
|
||||
icon: <Users className="w-8 h-8 text-purple-500" />,
|
||||
loading: loadingApprenants,
|
||||
},
|
||||
{
|
||||
title: "Sessions terminées",
|
||||
value: sessions?.filter(s => s.statut === "terminee").length || 0,
|
||||
icon: <CheckCircle className="w-8 h-8 text-orange-500" />,
|
||||
loading: loadingSessions,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tableau de bord</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gestion des formations Manager Itinova
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
{stat.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stat.loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations, sessions et apprenants depuis le menu de navigation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">Fonctionnalités principales :</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Créer et gérer les formations avec liens d'inscription uniques</li>
|
||||
<li>Organiser les sessions avec limitation à 12 participants</li>
|
||||
<li>Gérer les inscriptions avec blocage automatique à J-15</li>
|
||||
<li>Exporter les listes d'inscrits en PDF ou Excel</li>
|
||||
<li>Générer des invitations Outlook et envoyer des emails automatiques</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
244
client/src/pages/AdminApprenants.tsx
Normal file
244
client/src/pages/AdminApprenants.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminApprenants() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
prenom: "",
|
||||
email: "",
|
||||
codeEtablissement: "",
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: apprenants, isLoading } = trpc.apprenants.list.useQuery();
|
||||
|
||||
const createMutation = trpc.apprenants.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant créé avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.apprenants.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant mis à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.apprenants.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.apprenants.list.invalidate();
|
||||
toast.success("Apprenant supprimé avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "" });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (apprenant: any) => {
|
||||
setFormData({
|
||||
nom: apprenant.nom,
|
||||
prenom: apprenant.prenom,
|
||||
email: apprenant.email,
|
||||
codeEtablissement: apprenant.codeEtablissement,
|
||||
});
|
||||
setEditingId(apprenant.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cet apprenant ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Apprenants</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les apprenants et leurs informations
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvel apprenant
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier l'apprenant" : "Nouvel apprenant"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ajoutez les informations de l'apprenant
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Dupont"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prenom">Prénom *</Label>
|
||||
<Input
|
||||
id="prenom"
|
||||
value={formData.prenom}
|
||||
onChange={(e) => setFormData({ ...formData, prenom: e.target.value })}
|
||||
placeholder="Jean"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jean.dupont@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeEtablissement">Code établissement *</Label>
|
||||
<Input
|
||||
id="codeEtablissement"
|
||||
value={formData.codeEtablissement}
|
||||
onChange={(e) => setFormData({ ...formData, codeEtablissement: e.target.value })}
|
||||
placeholder="ETB001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des apprenants</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les apprenants inscrits aux formations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : apprenants && apprenants.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apprenants.map((apprenant) => (
|
||||
<TableRow key={apprenant.id}>
|
||||
<TableCell className="font-medium">{apprenant.nom}</TableCell>
|
||||
<TableCell>{apprenant.prenom}</TableCell>
|
||||
<TableCell>{apprenant.email}</TableCell>
|
||||
<TableCell>{apprenant.codeEtablissement}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(apprenant)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(apprenant.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun apprenant pour le moment. Créez-en un pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
265
client/src/pages/AdminFormations.tsx
Normal file
265
client/src/pages/AdminFormations.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminFormations() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
description: "",
|
||||
lienUnique: "",
|
||||
actif: true,
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: formations, isLoading } = trpc.formations.list.useQuery();
|
||||
const createMutation = trpc.formations.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation créée avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.formations.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation mise à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.formations.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.formations.list.invalidate();
|
||||
toast.success("Formation supprimée avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({ nom: "", description: "", lienUnique: "", actif: true });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (formation: any) => {
|
||||
setFormData({
|
||||
nom: formation.nom,
|
||||
description: formation.description || "",
|
||||
lienUnique: formation.lienUnique,
|
||||
actif: formation.actif,
|
||||
});
|
||||
setEditingId(formation.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette formation ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const getInscriptionUrl = (lien: string) => {
|
||||
return `${window.location.origin}/inscription/${lien}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Formations</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les formations et leurs liens d'inscription
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle formation
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier la formation" : "Nouvelle formation"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une formation avec un lien d'inscription unique
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom de la formation *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Manager Itinova"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Description de la formation..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lienUnique">Lien unique *</Label>
|
||||
<Input
|
||||
id="lienUnique"
|
||||
value={formData.lienUnique}
|
||||
onChange={(e) => setFormData({ ...formData, lienUnique: e.target.value })}
|
||||
placeholder="manager-itinova-2026"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ce lien sera utilisé pour l'inscription : /inscription/{formData.lienUnique}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="actif"
|
||||
checked={formData.actif}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, actif: checked })}
|
||||
/>
|
||||
<Label htmlFor="actif">Formation active</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des formations</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations et accédez aux liens d'inscription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : formations && formations.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Lien d'inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formations.map((formation) => (
|
||||
<TableRow key={formation.id}>
|
||||
<TableCell className="font-medium">{formation.nom}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{formation.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={getInscriptionUrl(formation.lienUnique)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:underline"
|
||||
>
|
||||
{formation.lienUnique}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
formation.actif
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}>
|
||||
{formation.actif ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(formation)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(formation.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune formation pour le moment. Créez-en une pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ArrowLeft, Download, Mail } from "lucide-react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSessionInscrits() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [, params] = useRoute("/admin/sessions/:id/inscrits");
|
||||
const sessionId = params?.id ? parseInt(params.id) : 0;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: session, isLoading: loadingSession } = trpc.sessions.getById.useQuery({ id: sessionId });
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySession.useQuery({ sessionId });
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inscriptions.listBySession.invalidate({ sessionId });
|
||||
toast.success("Statut mis à jour avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmailsMutation = trpc.inscriptions.sendGroupEmails.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'envoi : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatutChange = (inscriptionId: number, newStatut: string) => {
|
||||
updateStatutMutation.mutate({
|
||||
id: inscriptionId,
|
||||
statut: newStatut as "confirmee" | "liste_attente" | "annulee",
|
||||
});
|
||||
};
|
||||
|
||||
const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export Excel téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export PDF téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `feuille-presence-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Feuille de présence téléchargée");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la génération : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = () => {
|
||||
exportExcelMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleExportPDF = () => {
|
||||
exportPDFMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleGenerateFeuillePresence = () => {
|
||||
exportFeuilleMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
confirmee: "bg-green-100 text-green-800",
|
||||
liste_attente: "bg-orange-100 text-orange-800",
|
||||
annulee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
confirmee: "Confirmée",
|
||||
liste_attente: "Liste d'attente",
|
||||
annulee: "Annulée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const inscritsConfirmes = inscriptions?.filter(i => i.inscription.statut === "confirmee") || [];
|
||||
const listeAttente = inscriptions?.filter(i => i.inscription.statut === "liste_attente") || [];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setLocation("/admin/sessions")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Inscrits à la session</h1>
|
||||
{session && (
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{getFormationName(session.formationId)} - {session.nom}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSession || loadingInscriptions ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : session ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Places confirmées</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{inscritsConfirmes.length} / {session.capaciteMax}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{session.capaciteMax - inscritsConfirmes.length} places restantes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Liste d'attente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{listeAttente.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
En attente de places
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Total inscriptions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{inscriptions?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Toutes statuts confondus
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExportExcel}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportPDF}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGenerateFeuillePresence}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Feuille de présence
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'teaser' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer teaser
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'rappel' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer rappel J-7
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les inscriptions et leurs statuts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{inscriptions && inscriptions.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead>Date d'inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inscriptions.map((item) => (
|
||||
<TableRow key={item.inscription.id}>
|
||||
<TableCell className="font-medium">{item.apprenant?.nom}</TableCell>
|
||||
<TableCell>{item.apprenant?.prenom}</TableCell>
|
||||
<TableCell>{item.apprenant?.email}</TableCell>
|
||||
<TableCell>{item.apprenant?.codeEtablissement}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(item.inscription.dateInscription).toLocaleDateString('fr-FR')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatutBadge(item.inscription.statut)}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.inscription.statut}
|
||||
onValueChange={(value) => handleStatutChange(item.inscription.id, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune inscription pour cette session.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Session introuvable.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
377
client/src/pages/AdminSessions.tsx
Normal file
377
client/src/pages/AdminSessions.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2, Users as UsersIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function AdminSessions() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sessions, isLoading } = trpc.sessions.list.useQuery();
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const createMutation = trpc.sessions.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session créée avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.sessions.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session mise à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.sessions.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session supprimée avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte",
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (session: any) => {
|
||||
setFormData({
|
||||
formationId: session.formationId.toString(),
|
||||
nom: session.nom,
|
||||
dateDebut: format(new Date(session.dateDebut), "yyyy-MM-dd'T'HH:mm"),
|
||||
dateFin: format(new Date(session.dateFin), "yyyy-MM-dd'T'HH:mm"),
|
||||
lieu: session.lieu,
|
||||
capaciteMax: session.capaciteMax.toString(),
|
||||
dateBlocage: format(new Date(session.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
|
||||
statut: session.statut,
|
||||
});
|
||||
setEditingId(session.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
formationId: parseInt(formData.formationId),
|
||||
nom: formData.nom,
|
||||
dateDebut: formData.dateDebut,
|
||||
dateFin: formData.dateFin,
|
||||
lieu: formData.lieu,
|
||||
capaciteMax: parseInt(formData.capaciteMax),
|
||||
dateBlocage: formData.dateBlocage,
|
||||
statut: formData.statut,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette session ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr });
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
ouverte: "bg-green-100 text-green-800",
|
||||
bloquee: "bg-orange-100 text-orange-800",
|
||||
terminee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
ouverte: "Ouverte",
|
||||
bloquee: "Bloquée",
|
||||
terminee: "Terminée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les sessions de formation et leurs inscriptions
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle session
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier la session" : "Nouvelle session"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une session avec dates, lieu et capacité
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formationId">Formation *</Label>
|
||||
<Select
|
||||
value={formData.formationId}
|
||||
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez une formation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formations?.map((formation) => (
|
||||
<SelectItem key={formation.id} value={formation.id.toString()}>
|
||||
{formation.nom}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom de la session *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Session 1 - Février 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateDebut">Date de début *</Label>
|
||||
<Input
|
||||
id="dateDebut"
|
||||
type="datetime-local"
|
||||
value={formData.dateDebut}
|
||||
onChange={(e) => setFormData({ ...formData, dateDebut: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateFin">Date de fin *</Label>
|
||||
<Input
|
||||
id="dateFin"
|
||||
type="datetime-local"
|
||||
value={formData.dateFin}
|
||||
onChange={(e) => setFormData({ ...formData, dateFin: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lieu">Lieu *</Label>
|
||||
<Input
|
||||
id="lieu"
|
||||
value={formData.lieu}
|
||||
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
|
||||
placeholder="Salle de formation A, Bâtiment principal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
|
||||
<Input
|
||||
id="capaciteMax"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capaciteMax}
|
||||
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateBlocage">Date de blocage (J-15) *</Label>
|
||||
<Input
|
||||
id="dateBlocage"
|
||||
type="datetime-local"
|
||||
value={formData.dateBlocage}
|
||||
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="statut">Statut</Label>
|
||||
<Select
|
||||
value={formData.statut}
|
||||
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ouverte">Ouverte</SelectItem>
|
||||
<SelectItem value="bloquee">Bloquée</SelectItem>
|
||||
<SelectItem value="terminee">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos sessions et consultez les inscriptions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : sessions && sessions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Formation</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Date début</TableHead>
|
||||
<TableHead>Date fin</TableHead>
|
||||
<TableHead>Lieu</TableHead>
|
||||
<TableHead>Capacité</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell>
|
||||
<TableCell>{session.nom}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateDebut)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateFin)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{session.lieu}</TableCell>
|
||||
<TableCell>{session.capaciteMax}</TableCell>
|
||||
<TableCell>{getStatutBadge(session.statut)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation(`/admin/sessions/${session.id}/inscrits`)}
|
||||
title="Voir les inscrits"
|
||||
>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(session)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session pour le moment. Créez-en une pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,182 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { GraduationCap, Calendar, Users, CheckCircle, Mail, Download } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
/**
|
||||
* All content in this page are only for example, replace with your own feature implementation
|
||||
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
|
||||
*/
|
||||
export default function Home() {
|
||||
// The userAuth hooks provides authentication state
|
||||
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
|
||||
let { user, loading, error, isAuthenticated, logout } = useAuth();
|
||||
const { user, loading, isAuthenticated } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// If theme is switchable in App.tsx, we can implement theme toggling like this:
|
||||
// const { theme, toggleTheme } = useTheme();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use APP_LOGO (as image src) and APP_TITLE if needed
|
||||
if (isAuthenticated && user?.role === 'admin') {
|
||||
setLocation('/admin');
|
||||
return null;
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <GraduationCap className="w-8 h-8 text-blue-600" />,
|
||||
title: "Gestion des formations",
|
||||
description: "Créez et gérez plusieurs formations en parallèle avec des liens d'inscription uniques pour chaque formation.",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="w-8 h-8 text-green-600" />,
|
||||
title: "Sessions planifiées",
|
||||
description: "Organisez des sessions avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.",
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-8 h-8 text-purple-600" />,
|
||||
title: "Inscriptions simplifiées",
|
||||
description: "Les apprenants s'inscrivent facilement via un lien unique. Validation automatique de la capacité et anti-doublons.",
|
||||
},
|
||||
{
|
||||
icon: <Mail className="w-8 h-8 text-orange-600" />,
|
||||
title: "Communications automatiques",
|
||||
description: "Emails de confirmation, invitations Outlook avec statut 'occupé', teasers et rappels J-7 automatisés.",
|
||||
},
|
||||
{
|
||||
icon: <Download className="w-8 h-8 text-red-600" />,
|
||||
title: "Exports facilitées",
|
||||
description: "Exportez les listes d'inscrits en Excel ou PDF. Générez des feuilles de présence prêtes à imprimer.",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-8 h-8 text-teal-600" />,
|
||||
title: "Gestion des contraintes",
|
||||
description: "Respect automatique des règles : capacité maximale, blocage J-15, liste d'attente et prévention des doubles inscriptions.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<main>
|
||||
{/* Example: lucide-react for icons */}
|
||||
<Loader2 className="animate-spin" />
|
||||
Example Page
|
||||
{/* Example: Streamdown for markdown rendering */}
|
||||
<Streamdown>Any **markdown** content</Streamdown>
|
||||
<Button variant="default">Example Button</Button>
|
||||
</main>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={APP_LOGO} alt={APP_TITLE} className="h-10 w-10" />
|
||||
<h1 className="text-xl font-bold text-gray-900">{APP_TITLE}</h1>
|
||||
</div>
|
||||
{!isAuthenticated && (
|
||||
<Button onClick={() => window.location.href = getLoginUrl()}>
|
||||
Connexion Administrateur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-20 px-4">
|
||||
<div className="container mx-auto text-center max-w-4xl">
|
||||
<h2 className="text-5xl font-bold text-gray-900 mb-6">
|
||||
Gestion des Formations Manager Itinova
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Solution complète pour gérer l'organisation interne des formations destinées à environ 150 apprenants.
|
||||
Inscriptions, sessions, communications automatisées et exports simplifiés.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{isAuthenticated && user?.role === 'admin' && (
|
||||
<Button size="lg" onClick={() => setLocation('/admin')}>
|
||||
Accéder au tableau de bord
|
||||
</Button>
|
||||
)}
|
||||
{!isAuthenticated && (
|
||||
<Button size="lg" onClick={() => window.location.href = getLoginUrl()}>
|
||||
Connexion Administrateur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="py-16 px-4 bg-white">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<h3 className="text-3xl font-bold text-center mb-12">Fonctionnalités principales</h3>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<Card key={index} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="mb-4">{feature.icon}</div>
|
||||
<CardTitle className="text-xl">{feature.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<h3 className="text-3xl font-bold text-center mb-12">Comment ça fonctionne ?</h3>
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">1</span>
|
||||
Pour les administrateurs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Créez des formations et des sessions avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.</p>
|
||||
<p>Gérez les inscriptions, envoyez des emails groupés (teaser, rappels), et exportez les listes d'inscrits en Excel ou PDF pour vos besoins administratifs.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-green-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">2</span>
|
||||
Pour les apprenants
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Cliquez sur le lien d'inscription fourni par la DRH, consultez les sessions disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.</p>
|
||||
<p>Recevez immédiatement un email de confirmation avec une invitation Outlook (.ics) qui bloque automatiquement votre agenda. Un rappel vous sera envoyé 7 jours avant la formation.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="bg-purple-600 text-white rounded-full w-8 h-8 flex items-center justify-center font-bold">3</span>
|
||||
Règles automatiques
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par session, prévention des doubles inscriptions, et gestion de la liste d'attente.</p>
|
||||
<p>Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-8 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<p className="text-gray-400">
|
||||
© 2026 Itinova - Gestion des Formations Manager Itinova
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Pour toute question, veuillez contacter le service RH.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
346
client/src/pages/Inscription.tsx
Normal file
346
client/src/pages/Inscription.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useRoute } from "wouter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
|
||||
import { APP_LOGO, APP_TITLE } from "@/const";
|
||||
|
||||
export default function Inscription() {
|
||||
const [, params] = useRoute("/inscription/:lien");
|
||||
const lien = params?.lien || "";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
prenom: "",
|
||||
email: "",
|
||||
codeEtablissement: "",
|
||||
});
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<number | null>(null);
|
||||
const [inscriptionSuccess, setInscriptionSuccess] = useState(false);
|
||||
const [inscriptionStatut, setInscriptionStatut] = useState<string>("");
|
||||
|
||||
const { data: formation, isLoading: loadingFormation } = trpc.formations.getByLien.useQuery({ lien });
|
||||
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.listByFormation.useQuery(
|
||||
{ formationId: formation?.id || 0 },
|
||||
{ enabled: !!formation?.id }
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const inscriptionMutation = trpc.inscriptions.inscrire.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setInscriptionSuccess(true);
|
||||
setInscriptionStatut(data.statut);
|
||||
utils.sessions.listByFormation.invalidate({ formationId: formation?.id || 0 });
|
||||
if (data.statut === "confirmee") {
|
||||
toast.success("Inscription confirmée avec succès !");
|
||||
} else {
|
||||
toast.info("Vous êtes inscrit en liste d'attente.");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const createApprenantMutation = trpc.apprenants.create.useMutation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedSessionId) {
|
||||
toast.error("Veuillez sélectionner une session");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vérifier si l'apprenant existe déjà
|
||||
const existingApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
||||
|
||||
let apprenantId: number;
|
||||
|
||||
if (existingApprenant) {
|
||||
apprenantId = existingApprenant.id;
|
||||
} else {
|
||||
// Créer l'apprenant
|
||||
await createApprenantMutation.mutateAsync(formData);
|
||||
// Récupérer l'apprenant nouvellement créé
|
||||
const newApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
||||
if (!newApprenant) {
|
||||
throw new Error("Erreur lors de la création de l'apprenant");
|
||||
}
|
||||
apprenantId = newApprenant.id;
|
||||
}
|
||||
|
||||
// Inscrire l'apprenant à la session
|
||||
await inscriptionMutation.mutateAsync({
|
||||
apprenantId,
|
||||
sessionId: selectedSessionId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur lors de l'inscription:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer les places disponibles pour chaque session
|
||||
const sessionsAvecPlaces = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
|
||||
return sessions.map(session => {
|
||||
const now = new Date();
|
||||
const dateBlocage = new Date(session.dateBlocage);
|
||||
const isBloquee = session.statut === 'bloquee' || now >= dateBlocage;
|
||||
|
||||
return {
|
||||
...session,
|
||||
isBloquee,
|
||||
placesRestantes: session.capaciteMax,
|
||||
};
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
};
|
||||
|
||||
if (loadingFormation || loadingSessions) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formation || !formation.actif) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="w-6 h-6" />
|
||||
<CardTitle>Formation introuvable</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Cette formation n'existe pas ou n'est plus disponible.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inscriptionSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="w-8 h-8" />
|
||||
<CardTitle className="text-2xl">Inscription réussie !</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg">
|
||||
{inscriptionStatut === "confirmee"
|
||||
? "Votre inscription à la formation a été confirmée."
|
||||
: "Vous avez été ajouté à la liste d'attente. Nous vous contacterons si une place se libère."}
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg space-y-2">
|
||||
<p className="font-semibold">Prochaines étapes :</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Vous recevrez un email de confirmation avec tous les détails</li>
|
||||
<li>Une invitation Outlook sera envoyée pour bloquer votre agenda</li>
|
||||
<li>Un email de rappel vous sera envoyé 7 jours avant la formation</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pour toute question, veuillez contacter le service RH.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img src={APP_LOGO} alt={APP_TITLE} className="h-12 w-12" />
|
||||
<h1 className="text-4xl font-bold text-gray-900">{formation.nom}</h1>
|
||||
</div>
|
||||
{formation.description && (
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
{formation.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions disponibles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sessions disponibles</CardTitle>
|
||||
<CardDescription>
|
||||
Sélectionnez une session pour vous inscrire (capacité : 12 participants maximum)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessionsAvecPlaces.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedSessionId === session.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: session.isBloquee
|
||||
? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
|
||||
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
|
||||
}`}
|
||||
onClick={() => !session.isBloquee && setSelectedSessionId(session.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3 className="font-semibold text-lg">{session.nom}</h3>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(session.dateDebut)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{session.lieu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{session.placesRestantes} places disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{session.isBloquee ? (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Fermée
|
||||
</span>
|
||||
) : session.placesRestantes === 0 ? (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Complète
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Disponible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session disponible pour le moment.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Formulaire d'inscription */}
|
||||
{selectedSessionId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vos informations</CardTitle>
|
||||
<CardDescription>
|
||||
Complétez le formulaire pour finaliser votre inscription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Dupont"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prenom">Prénom *</Label>
|
||||
<Input
|
||||
id="prenom"
|
||||
value={formData.prenom}
|
||||
onChange={(e) => setFormData({ ...formData, prenom: e.target.value })}
|
||||
placeholder="Jean"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email professionnel *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jean.dupont@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codeEtablissement">Code établissement *</Label>
|
||||
<Input
|
||||
id="codeEtablissement"
|
||||
value={formData.codeEtablissement}
|
||||
onChange={(e) => setFormData({ ...formData, codeEtablissement: e.target.value })}
|
||||
placeholder="ETB001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session.
|
||||
Après cette date, aucune modification ne sera possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
|
||||
>
|
||||
{inscriptionMutation.isPending || createApprenantMutation.isPending
|
||||
? "Inscription en cours..."
|
||||
: "Confirmer mon inscription"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user