Rollback to 9d67724e
This commit is contained in:
154
client/src/pages/Admin.tsx
Normal file
154
client/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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, PieChart as PieChartIcon } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function Admin() {
|
||||
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
|
||||
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.list.useQuery();
|
||||
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
|
||||
|
||||
// Statistiques par fonction
|
||||
const statsFonction = useMemo(() => {
|
||||
if (!apprenants) return [];
|
||||
|
||||
const directeurs = apprenants.filter(a => a.fonction === "directeur").length;
|
||||
const chefsService = apprenants.filter(a => a.fonction === "chef_service").length;
|
||||
const autres = apprenants.filter(a => a.fonction === "autre").length;
|
||||
|
||||
return [
|
||||
{ name: "Directeurs", value: directeurs, color: "#3b82f6" },
|
||||
{ name: "Chefs de service", value: chefsService, color: "#10b981" },
|
||||
{ name: "Autres", value: autres, color: "#f59e0b" },
|
||||
].filter(item => item.value > 0);
|
||||
}, [apprenants]);
|
||||
|
||||
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: "Séquences ouvertes",
|
||||
value: sequences?.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: "Séquences terminées",
|
||||
value: sequences?.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>
|
||||
|
||||
{/* Graphique de répartition par fonction */}
|
||||
{statsFonction.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChartIcon className="w-5 h-5" />
|
||||
Répartition des apprenants par fonction
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution des {apprenants?.length || 0} apprenants selon leur fonction
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingApprenants ? (
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<Skeleton className="h-64 w-64 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statsFonction}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{statsFonction.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations, sequences 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 sequences 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>
|
||||
);
|
||||
}
|
||||
331
client/src/pages/AdminApprenants.tsx
Normal file
331
client/src/pages/AdminApprenants.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { useState, useMemo } 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 [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterFonction, setFilterFonction] = useState<string>("all");
|
||||
const [formData, setFormData] = useState({
|
||||
nom: "",
|
||||
prenom: "",
|
||||
email: "",
|
||||
codeEtablissement: "",
|
||||
fonction: "autre" as "directeur" | "chef_service" | "autre",
|
||||
});
|
||||
|
||||
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: "", fonction: "autre" });
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (apprenant: any) => {
|
||||
setFormData({
|
||||
nom: apprenant.nom,
|
||||
prenom: apprenant.prenom,
|
||||
email: apprenant.email,
|
||||
codeEtablissement: apprenant.codeEtablissement,
|
||||
fonction: apprenant.fonction || "autre",
|
||||
});
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrer les apprenants
|
||||
const filteredApprenants = useMemo(() => {
|
||||
if (!apprenants) return [];
|
||||
|
||||
return apprenants.filter(apprenant => {
|
||||
const matchesSearch = searchTerm === "" ||
|
||||
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction;
|
||||
|
||||
return matchesSearch && matchesFonction;
|
||||
});
|
||||
}, [apprenants, searchTerm, filterFonction]);
|
||||
|
||||
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 className="space-y-2">
|
||||
<Label htmlFor="fonction">Fonction *</Label>
|
||||
<Select value={formData.fonction} onValueChange={(value: any) => setFormData({ ...formData, fonction: value })}>
|
||||
<SelectTrigger id="fonction">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</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>
|
||||
|
||||
{/* Filtres et recherche */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
Recherche
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Rechercher un apprenant</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Nom, prénom, email ou code établissement..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterFonction">Filtrer par fonction</Label>
|
||||
<Select value={filterFonction} onValueChange={setFilterFonction}>
|
||||
<SelectTrigger id="filterFonction">
|
||||
<SelectValue placeholder="Toutes les fonctions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes les fonctions</SelectItem>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{filteredApprenants.length} apprenant(s) trouvé(s)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
) : filteredApprenants && filteredApprenants.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead>Fonction</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredApprenants.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>
|
||||
{apprenant.fonction === "directeur" ? "Directeur" :
|
||||
apprenant.fonction === "chef_service" ? "Chef de service" : "Autre"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
323
client/src/pages/AdminEmailConfig.tsx
Normal file
323
client/src/pages/AdminEmailConfig.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle2, Mail, Send } from "lucide-react";
|
||||
|
||||
export default function AdminEmailConfig() {
|
||||
const { data: config, isLoading, refetch } = trpc.emailConfig.get.useQuery();
|
||||
const upsertMutation = trpc.emailConfig.upsert.useMutation();
|
||||
const testEmailMutation = trpc.emailConfig.testEmail.useMutation();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
provider: "resend",
|
||||
apiKey: "",
|
||||
fromEmail: "",
|
||||
fromName: "Formation Manager Itinova",
|
||||
mode: "simulation" as "simulation" | "production",
|
||||
domainVerified: false,
|
||||
});
|
||||
|
||||
const [testEmail, setTestEmail] = useState("");
|
||||
|
||||
// Charger la configuration existante
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
provider: config.provider,
|
||||
apiKey: config.apiKey || "",
|
||||
fromEmail: config.fromEmail,
|
||||
fromName: config.fromName,
|
||||
mode: config.mode,
|
||||
domainVerified: config.domainVerified,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
upsertMutation.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
toast.success("Configuration enregistrée avec succès");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestEmail = () => {
|
||||
if (!testEmail) {
|
||||
toast.error("Veuillez saisir une adresse email");
|
||||
return;
|
||||
}
|
||||
|
||||
testEmailMutation.mutate(
|
||||
{ toEmail: testEmail },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(data.message || "Email de test envoyé avec succès");
|
||||
} else {
|
||||
toast.error(data.message || "Échec de l'envoi de l'email de test");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container py-8">
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container py-8 max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Configuration des emails</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Configurez l'envoi d'emails via Resend et basculez entre mode simulation et production
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Statut actuel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Statut actuel
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Mode d'envoi</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.mode === "simulation"
|
||||
? "Les emails sont simulés et envoyés comme notifications"
|
||||
: "Les emails sont envoyés via Resend"}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
formData.mode === "production"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
{formData.mode === "production" ? "Production" : "Simulation"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.apiKey && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Clé API Resend configurée
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!formData.apiKey && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Aucune clé API configurée. Les emails seront simulés.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Formulaire de configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration Resend</CardTitle>
|
||||
<CardDescription>
|
||||
Configurez votre compte Resend pour envoyer de vrais emails
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">Clé API Resend *</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="re_..."
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Obtenez votre clé API sur{" "}
|
||||
<a
|
||||
href="https://resend.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
resend.com/api-keys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromEmail">Email expéditeur *</Label>
|
||||
<Input
|
||||
id="fromEmail"
|
||||
type="email"
|
||||
placeholder="noreply@votredomaine.com"
|
||||
value={formData.fromEmail}
|
||||
onChange={(e) => setFormData({ ...formData, fromEmail: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
L'adresse email doit être vérifiée dans votre compte Resend
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromName">Nom de l'expéditeur *</Label>
|
||||
<Input
|
||||
id="fromName"
|
||||
type="text"
|
||||
placeholder="Formation Manager Itinova"
|
||||
value={formData.fromName}
|
||||
onChange={(e) => setFormData({ ...formData, fromName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="mode">Mode production</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Activer l'envoi réel d'emails (désactiver pour simuler)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="mode"
|
||||
checked={formData.mode === "production"}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, mode: checked ? "production" : "simulation" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={upsertMutation.isPending}>
|
||||
{upsertMutation.isPending ? "Enregistrement..." : "Enregistrer la configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test d'envoi */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-5 w-5" />
|
||||
Test d'envoi
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Envoyez un email de test pour vérifier votre configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testEmail">Adresse email de test</Label>
|
||||
<Input
|
||||
id="testEmail"
|
||||
type="email"
|
||||
placeholder="votre@email.com"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleTestEmail}
|
||||
disabled={testEmailMutation.isPending || !testEmail}
|
||||
variant="outline"
|
||||
>
|
||||
{testEmailMutation.isPending ? "Envoi en cours..." : "Envoyer un email de test"}
|
||||
</Button>
|
||||
|
||||
{formData.mode === "simulation" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Mode simulation actif. L'email de test sera simulé et envoyé comme notification au propriétaire.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documentation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Guide de configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Créer un compte Resend</h4>
|
||||
<p className="text-muted-foreground">
|
||||
Inscrivez-vous gratuitement sur{" "}
|
||||
<a href="https://resend.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
resend.com
|
||||
</a>{" "}
|
||||
(100 emails/jour gratuits)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Vérifier votre domaine</h4>
|
||||
<p className="text-muted-foreground">
|
||||
Ajoutez et vérifiez votre domaine dans les paramètres Resend pour pouvoir envoyer des emails
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">3. Obtenir la clé API</h4>
|
||||
<p className="text-muted-foreground">
|
||||
Générez une clé API dans la section "API Keys" de votre compte Resend
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">4. Configurer et tester</h4>
|
||||
<p className="text-muted-foreground">
|
||||
Saisissez vos informations ci-dessus, enregistrez, puis testez l'envoi avant de passer en mode production
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
364
client/src/pages/AdminEmailTemplates.tsx
Normal file
364
client/src/pages/AdminEmailTemplates.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Mail, Eye, Save, RotateCcw } from "lucide-react";
|
||||
|
||||
export default function AdminEmailTemplates() {
|
||||
const [selectedType, setSelectedType] = useState<string>("inscription");
|
||||
const [formData, setFormData] = useState({
|
||||
type: "inscription",
|
||||
name: "",
|
||||
logoUrl: "",
|
||||
primaryColor: "#2563eb",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "Formation Manager Itinova",
|
||||
footerText: "",
|
||||
active: true,
|
||||
});
|
||||
|
||||
const { data: templates, refetch } = trpc.emailTemplates.list.useQuery();
|
||||
const { data: currentTemplate } = trpc.emailTemplates.getByType.useQuery(
|
||||
{ type: selectedType },
|
||||
{ enabled: !!selectedType }
|
||||
);
|
||||
|
||||
const upsertMutation = trpc.emailTemplates.upsert.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Template enregistré avec succès");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const initMutation = trpc.emailTemplates.initializeDefaults.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Templates par défaut initialisés");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Charger le template sélectionné
|
||||
useEffect(() => {
|
||||
if (currentTemplate) {
|
||||
setFormData({
|
||||
type: currentTemplate.type,
|
||||
name: currentTemplate.name,
|
||||
logoUrl: currentTemplate.logoUrl || "",
|
||||
primaryColor: currentTemplate.primaryColor,
|
||||
headerBgColor: currentTemplate.headerBgColor,
|
||||
headerTextColor: currentTemplate.headerTextColor,
|
||||
headerTitle: currentTemplate.headerTitle,
|
||||
footerText: currentTemplate.footerText || "",
|
||||
active: currentTemplate.active,
|
||||
});
|
||||
}
|
||||
}, [currentTemplate]);
|
||||
|
||||
const handleSave = () => {
|
||||
upsertMutation.mutate({
|
||||
...formData,
|
||||
logoUrl: formData.logoUrl || null,
|
||||
footerText: formData.footerText || null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (currentTemplate) {
|
||||
setFormData({
|
||||
type: currentTemplate.type,
|
||||
name: currentTemplate.name,
|
||||
logoUrl: currentTemplate.logoUrl || "",
|
||||
primaryColor: currentTemplate.primaryColor,
|
||||
headerBgColor: currentTemplate.headerBgColor,
|
||||
headerTextColor: currentTemplate.headerTextColor,
|
||||
headerTitle: currentTemplate.headerTitle,
|
||||
footerText: currentTemplate.footerText || "",
|
||||
active: currentTemplate.active,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitDefaults = () => {
|
||||
if (confirm("Initialiser les templates par défaut ? Cela ne modifiera pas les templates existants.")) {
|
||||
initMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
// Générer la prévisualisation HTML
|
||||
const generatePreview = () => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background-color: ${formData.headerBgColor}; color: ${formData.headerTextColor}; padding: 30px 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
${formData.logoUrl ? `.header img { max-width: 150px; margin-bottom: 15px; }` : ''}
|
||||
.content { background-color: #f9fafb; padding: 30px 20px; }
|
||||
.content h2 { color: ${formData.primaryColor}; margin-top: 0; }
|
||||
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: ${formData.primaryColor}; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||
.info-box { background-color: #dbeafe; border-left: 4px solid ${formData.primaryColor}; padding: 15px; margin: 15px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${formData.logoUrl ? `<img src="${formData.logoUrl}" alt="Logo" />` : ''}
|
||||
<h1>${formData.headerTitle}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Exemple de contenu</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Ceci est un exemple de prévisualisation de votre template d'email personnalisé.</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Information importante</strong></p>
|
||||
<p>Ce bloc utilise la couleur principale que vous avez choisie.</p>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<a href="#" class="button">Bouton d'action</a>
|
||||
</div>
|
||||
<p>Cordialement,<br/>L'équipe Formation Manager Itinova</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>${formData.footerText || 'Texte du pied de page'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
};
|
||||
|
||||
const templateTypes = [
|
||||
{ value: "inscription", label: "Confirmation d'inscription" },
|
||||
{ value: "teaser", label: "Email teaser" },
|
||||
{ value: "rappel", label: "Rappel J-7" },
|
||||
{ value: "reset_password", label: "Réinitialisation de mot de passe" },
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Templates d'emails</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Personnalisez l'apparence de vos emails automatiques
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(!templates || templates.length === 0) && (
|
||||
<Card className="mb-6 border-yellow-200 bg-yellow-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Initialisation requise
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aucun template trouvé. Initialisez les templates par défaut pour commencer.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={handleInitDefaults} disabled={initMutation.isPending}>
|
||||
{initMutation.isPending ? "Initialisation..." : "Initialiser les templates par défaut"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Éditeur */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Éditeur de template</CardTitle>
|
||||
<CardDescription>
|
||||
Modifiez les paramètres du template sélectionné
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="templateType">Type de template</Label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedType(value);
|
||||
setFormData((prev) => ({ ...prev, type: value }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="templateType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du template</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Ex: Confirmation d'inscription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logoUrl">URL du logo (optionnel)</Label>
|
||||
<Input
|
||||
id="logoUrl"
|
||||
value={formData.logoUrl}
|
||||
onChange={(e) => setFormData({ ...formData, logoUrl: e.target.value })}
|
||||
placeholder="https://exemple.com/logo.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="headerBgColor">Couleur fond en-tête</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="headerBgColor"
|
||||
type="color"
|
||||
value={formData.headerBgColor}
|
||||
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={formData.headerBgColor}
|
||||
onChange={(e) => setFormData({ ...formData, headerBgColor: e.target.value })}
|
||||
placeholder="#2563eb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="headerTextColor">Couleur texte en-tête</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="headerTextColor"
|
||||
type="color"
|
||||
value={formData.headerTextColor}
|
||||
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={formData.headerTextColor}
|
||||
onChange={(e) => setFormData({ ...formData, headerTextColor: e.target.value })}
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="primaryColor">Couleur principale</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="primaryColor"
|
||||
type="color"
|
||||
value={formData.primaryColor}
|
||||
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={formData.primaryColor}
|
||||
onChange={(e) => setFormData({ ...formData, primaryColor: e.target.value })}
|
||||
placeholder="#2563eb"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Utilisée pour les boutons et les accents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="headerTitle">Titre de l'en-tête</Label>
|
||||
<Input
|
||||
id="headerTitle"
|
||||
value={formData.headerTitle}
|
||||
onChange={(e) => setFormData({ ...formData, headerTitle: e.target.value })}
|
||||
placeholder="Formation Manager Itinova"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="footerText">Texte du pied de page</Label>
|
||||
<Textarea
|
||||
id="footerText"
|
||||
value={formData.footerText}
|
||||
onChange={(e) => setFormData({ ...formData, footerText: e.target.value })}
|
||||
placeholder="Cet email a été envoyé automatiquement..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={handleSave} disabled={upsertMutation.isPending} className="flex-1">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{upsertMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Réinitialiser
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prévisualisation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Prévisualisation en temps réel
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aperçu de votre template avec les paramètres actuels
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50">
|
||||
<iframe
|
||||
srcDoc={generatePreview()}
|
||||
className="w-full h-[600px] border-0"
|
||||
title="Prévisualisation du template"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
335
client/src/pages/AdminFormations.tsx
Normal file
335
client/src/pages/AdminFormations.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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 { 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, ExternalLink, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { useState, useMemo } 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 [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterActif, setFilterActif] = useState<string>("all");
|
||||
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}`;
|
||||
};
|
||||
|
||||
// Filtrer les formations
|
||||
const filteredFormations = useMemo(() => {
|
||||
if (!formations) return [];
|
||||
|
||||
return formations.filter(formation => {
|
||||
// Filtre par recherche
|
||||
const matchesSearch = searchTerm === "" ||
|
||||
formation.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(formation.description && formation.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
// Filtre par statut actif
|
||||
const matchesActif = filterActif === "all" ||
|
||||
(filterActif === "actif" && formation.actif) ||
|
||||
(filterActif === "inactif" && !formation.actif);
|
||||
|
||||
return matchesSearch && matchesActif;
|
||||
});
|
||||
}, [formations, searchTerm, filterActif]);
|
||||
|
||||
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>
|
||||
|
||||
{/* Filtres et recherche */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
Filtres et recherche
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Recherche */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Rechercher</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Nom ou description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtre par statut */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterActif">Statut</Label>
|
||||
<Select value={filterActif} onValueChange={setFilterActif}>
|
||||
<SelectTrigger id="filterActif">
|
||||
<SelectValue placeholder="Tous les statuts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="actif">Active</SelectItem>
|
||||
<SelectItem value="inactif">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{filteredFormations.length} formation(s) trouvée(s)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
) : filteredFormations && filteredFormations.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>
|
||||
{filteredFormations.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>
|
||||
);
|
||||
}
|
||||
280
client/src/pages/AdminRapportPublicCible.tsx
Normal file
280
client/src/pages/AdminRapportPublicCible.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { BarChart3, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function AdminRapportPublicCible() {
|
||||
const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery();
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listAll.useQuery();
|
||||
|
||||
// Calculer les statistiques d'écart entre public cible et fonction des apprenants
|
||||
const stats = useMemo(() => {
|
||||
if (!sequences || !inscriptions) return null;
|
||||
|
||||
const sequencesWithMismatch = sequences.map((sequence) => {
|
||||
const sequenceInscriptions = inscriptions.filter(
|
||||
(insc: any) => insc.inscription.sequenceId === sequence.id && insc.inscription.statut === "confirmee"
|
||||
);
|
||||
|
||||
const totalInscrits = sequenceInscriptions.length;
|
||||
const mismatchCount = sequenceInscriptions.filter((insc: any) => {
|
||||
const apprenantFonction = insc.apprenant.fonction;
|
||||
const sequencePublicCible = sequence.publicCible;
|
||||
|
||||
// Mapper les fonctions aux publics cibles
|
||||
const fonctionToPublicCible: Record<string, string> = {
|
||||
directeur: "directeur",
|
||||
chef_service: "chef_service",
|
||||
autre: "autre",
|
||||
};
|
||||
|
||||
return fonctionToPublicCible[apprenantFonction] !== sequencePublicCible;
|
||||
}).length;
|
||||
|
||||
const matchRate = totalInscrits > 0 ? ((totalInscrits - mismatchCount) / totalInscrits) * 100 : 100;
|
||||
|
||||
return {
|
||||
sequence,
|
||||
totalInscrits,
|
||||
mismatchCount,
|
||||
matchCount: totalInscrits - mismatchCount,
|
||||
matchRate,
|
||||
};
|
||||
});
|
||||
|
||||
// Statistiques globales
|
||||
const totalInscrits = sequencesWithMismatch.reduce((sum, s) => sum + s.totalInscrits, 0);
|
||||
const totalMismatches = sequencesWithMismatch.reduce((sum, s) => sum + s.mismatchCount, 0);
|
||||
const globalMatchRate = totalInscrits > 0 ? ((totalInscrits - totalMismatches) / totalInscrits) * 100 : 100;
|
||||
|
||||
return {
|
||||
sequencesWithMismatch: sequencesWithMismatch.filter((s) => s.totalInscrits > 0),
|
||||
totalInscrits,
|
||||
totalMismatches,
|
||||
totalMatches: totalInscrits - totalMismatches,
|
||||
globalMatchRate,
|
||||
};
|
||||
}, [sequences, inscriptions]);
|
||||
|
||||
const getPublicCibleLabel = (publicCible: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||
};
|
||||
|
||||
const getPublicCibleBadge = (publicCible: string) => {
|
||||
const colors = {
|
||||
directeur: "bg-blue-100 text-blue-800",
|
||||
chef_service: "bg-green-100 text-green-800",
|
||||
autre: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||
};
|
||||
|
||||
const getMatchRateBadge = (matchRate: number) => {
|
||||
if (matchRate >= 80) return "bg-green-100 text-green-800";
|
||||
if (matchRate >= 50) return "bg-orange-100 text-orange-800";
|
||||
return "bg-red-100 text-red-800";
|
||||
};
|
||||
|
||||
if (loadingSequences || loadingInscriptions) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="text-center py-8 text-muted-foreground">Chargement...</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="text-center py-8 text-muted-foreground">Aucune donnée disponible</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Rapport d'analyse Public Cible</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Comparaison entre le public cible des séquences et la fonction des apprenants inscrits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistiques globales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Inscrits</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalInscrits}</div>
|
||||
<p className="text-xs text-muted-foreground">Inscrits confirmés</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Correspondances</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.totalMatches}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.globalMatchRate.toFixed(1)}% de correspondance
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Écarts</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.totalMismatches}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(100 - stats.globalMatchRate).toFixed(1)}% d'écart
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Séquences Analysées</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.sequencesWithMismatch.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Avec inscrits confirmés</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tableau détaillé par séquence */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analyse détaillée par séquence</CardTitle>
|
||||
<CardDescription>
|
||||
Taux de correspondance entre le public cible et la fonction des apprenants inscrits
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.sequencesWithMismatch.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune séquence avec des inscrits confirmés
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Séquence</TableHead>
|
||||
<TableHead>Public cible</TableHead>
|
||||
<TableHead className="text-right">Total Inscrits</TableHead>
|
||||
<TableHead className="text-right">Correspondances</TableHead>
|
||||
<TableHead className="text-right">Écarts</TableHead>
|
||||
<TableHead className="text-right">Taux de correspondance</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stats.sequencesWithMismatch
|
||||
.sort((a, b) => a.matchRate - b.matchRate)
|
||||
.map((stat) => (
|
||||
<TableRow key={stat.sequence.id}>
|
||||
<TableCell className="font-medium">{stat.sequence.nom}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(stat.sequence.publicCible)}`}
|
||||
>
|
||||
{getPublicCibleLabel(stat.sequence.publicCible)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{stat.totalInscrits}</TableCell>
|
||||
<TableCell className="text-right text-green-600 font-medium">
|
||||
{stat.matchCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-orange-600 font-medium">
|
||||
{stat.mismatchCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMatchRateBadge(stat.matchRate)}`}
|
||||
>
|
||||
{stat.matchRate.toFixed(1)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recommandations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recommandations</CardTitle>
|
||||
<CardDescription>Actions suggérées pour optimiser la planification des formations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{stats.globalMatchRate < 70 && (
|
||||
<div className="flex gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">Taux de correspondance faible</p>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Le taux de correspondance global est inférieur à 70%. Considérez la création de séquences
|
||||
spécifiques pour chaque public cible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.sequencesWithMismatch.some((s) => s.matchRate < 50) && (
|
||||
<div className="flex gap-3 p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-orange-900">Séquences à revoir</p>
|
||||
<p className="text-sm text-orange-700 mt-1">
|
||||
Certaines séquences ont un taux de correspondance inférieur à 50%. Vérifiez si le public
|
||||
cible est correctement défini ou si les apprenants sont inscrits aux bonnes séquences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.globalMatchRate >= 80 && (
|
||||
<div className="flex gap-3 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Excellente correspondance</p>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Le taux de correspondance global est excellent (≥ 80%). La planification des formations
|
||||
est bien alignée avec les publics cibles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
557
client/src/pages/AdminSequenceInscrits.tsx
Normal file
557
client/src/pages/AdminSequenceInscrits.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
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, Search } from "lucide-react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSequenceInscrits() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [, params] = useRoute("/admin/sequences/:id/inscrits");
|
||||
const sequenceId = params?.id ? parseInt(params.id) : 0;
|
||||
|
||||
const getPublicCibleBadge = (publicCible: string) => {
|
||||
const colors = {
|
||||
directeur: "bg-blue-100 text-blue-800",
|
||||
chef_service: "bg-green-100 text-green-800",
|
||||
autre: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||
};
|
||||
|
||||
const getPublicCibleLabel = (publicCible: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||
};
|
||||
|
||||
// États pour les filtres
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatut, setFilterStatut] = useState<string>("all");
|
||||
const [filterEtablissement, setFilterEtablissement] = useState<string>("all");
|
||||
const [filterFonction, setFilterFonction] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<"date" | "nom">("date");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySequence.useQuery({ sequenceId });
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inscriptions.listBySequence.invalidate({ sequenceId });
|
||||
toast.success("Statut mis à jour avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmailsMutation = trpc.inscriptions.sendGroupEmail.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.buffer), 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 = result.filename;
|
||||
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.buffer), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
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.buffer), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Feuille de présence téléchargée");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = () => {
|
||||
exportExcelMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleExportPDF = () => {
|
||||
exportPDFMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleExportFeuille = () => {
|
||||
exportFeuilleMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleSendEmails = (type: "teaser" | "rappel") => {
|
||||
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${type === "teaser" ? "teaser" : "de rappel J-7"} à tous les inscrits confirmés ?`)) {
|
||||
sendEmailsMutation.mutate({ sequenceId, type });
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage et tri
|
||||
const filteredAndSortedInscriptions = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
|
||||
let filtered = inscriptions.filter((item) => {
|
||||
if (!item.apprenant) return false;
|
||||
|
||||
const matchSearch =
|
||||
item.apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.apprenant.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchStatut = filterStatut === "all" || item.inscription.statut === filterStatut;
|
||||
const matchEtablissement = filterEtablissement === "all" || item.apprenant.codeEtablissement === filterEtablissement;
|
||||
const matchFonction = filterFonction === "all" || item.apprenant.fonction === filterFonction;
|
||||
|
||||
return matchSearch && matchStatut && matchEtablissement && matchFonction;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "date") {
|
||||
return new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime();
|
||||
} else {
|
||||
return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || "");
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]);
|
||||
|
||||
const uniqueEtablissements = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
return Array.from(new Set(inscriptions.map((i) => i.apprenant?.codeEtablissement).filter(Boolean)));
|
||||
}, [inscriptions]);
|
||||
|
||||
const getFormationName = () => {
|
||||
if (!sequence || !formations) return "N/A";
|
||||
return formations.find((f) => f.id === sequence.formationId)?.nom || "N/A";
|
||||
};
|
||||
|
||||
const formatDate = (dateValue: any) => {
|
||||
if (!dateValue) return "N/A";
|
||||
|
||||
try {
|
||||
// Si c'est déjà un objet Date JavaScript
|
||||
if (dateValue instanceof Date) {
|
||||
const day = String(dateValue.getDate()).padStart(2, '0');
|
||||
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
|
||||
const year = dateValue.getFullYear();
|
||||
const hours = String(dateValue.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Parser la date MySQL (chaîne)
|
||||
const dateStr = String(dateValue);
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
console.warn('Format de date non reconnu:', dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
console.error('Erreur de formatage de date:', error, dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const colors = {
|
||||
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 {
|
||||
color: colors[statut as keyof typeof colors] || colors.confirmee,
|
||||
label: labels[statut as keyof typeof labels] || statut,
|
||||
};
|
||||
};
|
||||
|
||||
const getFonctionLabel = (fonction: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[fonction as keyof typeof labels] || fonction;
|
||||
};
|
||||
|
||||
if (loadingSequence || loadingInscriptions) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sequence) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Séquence introuvable</CardTitle>
|
||||
<CardDescription>La séquence demandée n'existe pas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setLocation("/admin/sequences")}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour aux séquences
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const nbConfirmes = inscriptions?.filter((i) => i.inscription.statut === "confirmee").length || 0;
|
||||
const nbListeAttente = inscriptions?.filter((i) => i.inscription.statut === "liste_attente").length || 0;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Button variant="ghost" onClick={() => setLocation("/admin/sequences")} className="mb-2">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour aux séquences
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Inscrits à la séquence</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{getFormationName()} - {sequence.nom}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informations de la séquence */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informations de la séquence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Formation</p>
|
||||
<p className="text-base">{getFormationName()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Nom de la séquence</p>
|
||||
<p className="text-base">{sequence.nom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
|
||||
<p className="text-base">{sequence.lieu}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
|
||||
<p className="text-base">
|
||||
{nbConfirmes} / {sequence.capaciteMax} inscrits confirmés
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
|
||||
{getPublicCibleLabel(sequence.publicCible)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
|
||||
<div className="space-y-2">
|
||||
{sequence.dates.map((date: any) => (
|
||||
<div key={date.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">Date {date.ordre}:</span>
|
||||
<span>
|
||||
Du {formatDate(date.dateDebut)} au {formatDate(date.dateFin)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
<CardDescription>Envoi d'emails et exports</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSendEmails("teaser")}
|
||||
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Envoyer email teaser
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSendEmails("rappel")}
|
||||
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Envoyer rappel J-7
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportExcel}
|
||||
disabled={exportExcelMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportPDF}
|
||||
disabled={exportPDFMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportFeuille}
|
||||
disabled={exportFeuilleMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Feuille de présence
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Filtres et recherche
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recherche</Label>
|
||||
<Input
|
||||
placeholder="Nom, prénom, email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Statut</Label>
|
||||
<Select value={filterStatut} onValueChange={setFilterStatut}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous</SelectItem>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Établissement</Label>
|
||||
<Select value={filterEtablissement} onValueChange={setFilterEtablissement}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous</SelectItem>
|
||||
{uniqueEtablissements.map((etab) => (
|
||||
<SelectItem key={etab} value={etab as string}>
|
||||
{etab}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Fonction</Label>
|
||||
<Select value={filterFonction} onValueChange={setFilterFonction}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Trier par</Label>
|
||||
<Select value={sortBy} onValueChange={(v: any) => setSortBy(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date d'inscription</SelectItem>
|
||||
<SelectItem value="nom">Nom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
{filteredAndSortedInscriptions.length} inscription(s) trouvée(s) sur {inscriptions?.length || 0} au total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des inscrits */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
{nbConfirmes} confirmé(s), {nbListeAttente} en liste d'attente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredAndSortedInscriptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune inscription trouvée
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code étab.</TableHead>
|
||||
<TableHead>Fonction</TableHead>
|
||||
<TableHead>Date inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedInscriptions.map((item) => {
|
||||
if (!item.apprenant) return null;
|
||||
const statutInfo = getStatutBadge(item.inscription.statut);
|
||||
|
||||
return (
|
||||
<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>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDate(item.inscription.dateInscription)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statutInfo.color}`}>
|
||||
{statutInfo.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
853
client/src/pages/AdminSequences.tsx
Normal file
853
client/src/pages/AdminSequences.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
import { useState, useMemo } from "react";
|
||||
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, 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 { Calendar, Edit, Eye, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocation } from "wouter";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface DateFormation {
|
||||
dateDebut: string;
|
||||
dateFin: string;
|
||||
ordre: number;
|
||||
}
|
||||
|
||||
export default function AdminSequences() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editingSequence, setEditingSequence] = useState<any>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterLieu, setFilterLieu] = useState<string>("all");
|
||||
const [filterStatut, setFilterStatut] = useState<string>("all");
|
||||
const [filterPublicCible, setFilterPublicCible] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date");
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
lieu: "",
|
||||
publicCible: "autre" as "directeur" | "chef_service" | "autre",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
|
||||
dates: [
|
||||
{ dateDebut: "", dateFin: "", ordre: 1 }
|
||||
] as DateFormation[],
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
|
||||
|
||||
const createMutation = trpc.sequences.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Séquence créée avec succès");
|
||||
setIsCreateOpen(false);
|
||||
resetForm();
|
||||
utils.sequences.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.sequences.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Séquence modifiée avec succès");
|
||||
setIsEditOpen(false);
|
||||
setEditingSequence(null);
|
||||
// Forcer un refetch immédiat au lieu d'une simple invalidation
|
||||
await utils.sequences.list.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.sequences.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Séquence supprimée avec succès");
|
||||
utils.sequences.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
lieu: "",
|
||||
publicCible: "autre",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte",
|
||||
dates: [{ dateDebut: "", dateFin: "", ordre: 1 }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (sequence: any) => {
|
||||
setEditingSequence(sequence);
|
||||
|
||||
// Fonction helper pour formater une date en toute sécurité
|
||||
const safeFormatDate = (dateValue: any): string => {
|
||||
if (!dateValue) return "";
|
||||
try {
|
||||
// Si c'est un objet Date JavaScript
|
||||
if (dateValue instanceof Date) {
|
||||
const year = dateValue.getFullYear();
|
||||
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dateValue.getDate()).padStart(2, '0');
|
||||
const hours = String(dateValue.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Si c'est déjà une chaîne au bon format, la retourner directement
|
||||
if (typeof dateValue === 'string' && dateValue.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)) {
|
||||
return dateValue.substring(0, 16); // Garder seulement YYYY-MM-DDTHH:mm
|
||||
}
|
||||
|
||||
// Parser la date MySQL (format: "YYYY-MM-DD HH:mm:ss")
|
||||
const dateStr = String(dateValue);
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
console.warn('Format de date non reconnu:', dateValue);
|
||||
return "";
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
console.error('Erreur de formatage de date:', error, dateValue);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
setFormData({
|
||||
formationId: sequence.formationId.toString(),
|
||||
nom: sequence.nom || "",
|
||||
lieu: sequence.lieu || "",
|
||||
publicCible: sequence.publicCible || "autre",
|
||||
capaciteMax: sequence.capaciteMax?.toString() || "12",
|
||||
dateBlocage: safeFormatDate(sequence.dateBlocage),
|
||||
statut: sequence.statut || "ouverte",
|
||||
dates: (sequence.dates || []).map((d: any) => ({
|
||||
dateDebut: safeFormatDate(d.dateDebut),
|
||||
dateFin: safeFormatDate(d.dateFin),
|
||||
ordre: d.ordre || 1,
|
||||
})),
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.dates.length) {
|
||||
toast.error("Veuillez ajouter au moins une date de formation");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
formationId: parseInt(formData.formationId),
|
||||
nom: formData.nom,
|
||||
lieu: formData.lieu,
|
||||
publicCible: formData.publicCible,
|
||||
capaciteMax: parseInt(formData.capaciteMax),
|
||||
dateBlocage: formData.dateBlocage,
|
||||
statut: formData.statut,
|
||||
dates: formData.dates,
|
||||
};
|
||||
|
||||
if (editingSequence) {
|
||||
console.log('[CLIENT] Envoi de la mise à jour:', { id: editingSequence.id, ...data });
|
||||
updateMutation.mutate({ id: editingSequence.id, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette séquence ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const addDate = () => {
|
||||
if (formData.dates.length >= 4) {
|
||||
toast.error("Maximum 4 dates par séquence");
|
||||
return;
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
dates: [...formData.dates, { dateDebut: "", dateFin: "", ordre: formData.dates.length + 1 }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeDate = (index: number) => {
|
||||
if (formData.dates.length <= 1) {
|
||||
toast.error("Au moins une date est requise");
|
||||
return;
|
||||
}
|
||||
const newDates = formData.dates.filter((_, i) => i !== index);
|
||||
// Réordonner les dates
|
||||
newDates.forEach((d, i) => d.ordre = i + 1);
|
||||
setFormData({ ...formData, dates: newDates });
|
||||
};
|
||||
|
||||
const updateDate = (index: number, field: "dateDebut" | "dateFin", value: string) => {
|
||||
const newDates = [...formData.dates];
|
||||
newDates[index][field] = value;
|
||||
setFormData({ ...formData, dates: newDates });
|
||||
};
|
||||
|
||||
// Filtrage et tri
|
||||
const filteredAndSortedSequences = useMemo(() => {
|
||||
if (!sequences) return [];
|
||||
|
||||
let filtered = sequences.filter((seq) => {
|
||||
const matchSearch = seq.nom.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchLieu = filterLieu === "all" || seq.lieu === filterLieu;
|
||||
const matchStatut = filterStatut === "all" || seq.statut === filterStatut;
|
||||
const matchPublicCible = filterPublicCible === "all" || seq.publicCible === filterPublicCible;
|
||||
return matchSearch && matchLieu && matchStatut && matchPublicCible;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "date") {
|
||||
const dateA = a.dates[0] ? new Date(a.dates[0].dateDebut).getTime() : 0;
|
||||
const dateB = b.dates[0] ? new Date(b.dates[0].dateDebut).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
} else if (sortBy === "lieu") {
|
||||
return a.lieu.localeCompare(b.lieu);
|
||||
} else {
|
||||
return b.capaciteMax - a.capaciteMax;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [sequences, searchTerm, filterLieu, filterStatut, filterPublicCible, sortBy]);
|
||||
|
||||
const uniqueLieux = useMemo(() => {
|
||||
if (!sequences) return [];
|
||||
return Array.from(new Set(sequences.map((s) => s.lieu)));
|
||||
}, [sequences]);
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find((f) => f.id === formationId)?.nom || "N/A";
|
||||
};
|
||||
|
||||
const formatDate = (dateValue: any) => {
|
||||
if (!dateValue) return "N/A";
|
||||
|
||||
try {
|
||||
// Si c'est déjà un objet Date JavaScript
|
||||
if (dateValue instanceof Date) {
|
||||
const day = String(dateValue.getDate()).padStart(2, '0');
|
||||
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
|
||||
const year = dateValue.getFullYear();
|
||||
const hours = String(dateValue.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Parser la date MySQL (chaîne)
|
||||
const dateStr = String(dateValue);
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
console.warn('Format de date non reconnu pour affichage:', dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
console.error('Erreur de formatage de date pour affichage:', error, dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const colors = {
|
||||
ouverte: "bg-green-100 text-green-800",
|
||||
bloquee: "bg-orange-100 text-orange-800",
|
||||
terminee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return colors[statut as keyof typeof colors] || colors.ouverte;
|
||||
};
|
||||
|
||||
const getPublicCibleBadge = (publicCible: string) => {
|
||||
const colors = {
|
||||
directeur: "bg-blue-100 text-blue-800",
|
||||
chef_service: "bg-green-100 text-green-800",
|
||||
autre: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||
};
|
||||
|
||||
const getPublicCibleLabel = (publicCible: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Gestion des Séquences</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Créez et gérez les séquences de formation avec leurs dates
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={resetForm}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nouvelle Séquence
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle séquence</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remplissez les informations de la séquence et ajoutez jusqu'à 4 dates de formation
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-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 séquence *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Ex: Séquence 1 - Février 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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="Ex: Salle de formation A"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="publicCible">Public cible *</Label>
|
||||
<Select
|
||||
value={formData.publicCible}
|
||||
onValueChange={(value: "directeur" | "chef_service" | "autre") => setFormData({ ...formData, publicCible: value })}
|
||||
>
|
||||
<SelectTrigger id="publicCible">
|
||||
<SelectValue placeholder="Sélectionner le public cible" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
|
||||
<Input
|
||||
id="capaciteMax"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={formData.capaciteMax}
|
||||
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 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>
|
||||
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">Dates de formation ({formData.dates.length}/4)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDate}
|
||||
disabled={formData.dates.length >= 4}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter une date
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.dates.map((date, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">Date {date.ordre}</CardTitle>
|
||||
{formData.dates.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDate(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Début *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={date.dateDebut}
|
||||
onChange={(e) => updateDate(index, "dateDebut", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fin *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={date.dateFin}
|
||||
onChange={(e) => updateDate(index, "dateFin", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filtres et recherche</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recherche</Label>
|
||||
<Input
|
||||
placeholder="Nom de la séquence..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Lieu</Label>
|
||||
<Select value={filterLieu} onValueChange={setFilterLieu}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les lieux</SelectItem>
|
||||
{uniqueLieux.map((lieu) => (
|
||||
<SelectItem key={lieu} value={lieu}>
|
||||
{lieu}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Statut</Label>
|
||||
<Select value={filterStatut} onValueChange={setFilterStatut}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="ouverte">Ouverte</SelectItem>
|
||||
<SelectItem value="bloquee">Bloquée</SelectItem>
|
||||
<SelectItem value="terminee">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Public cible</Label>
|
||||
<Select value={filterPublicCible} onValueChange={setFilterPublicCible}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les publics</SelectItem>
|
||||
<SelectItem value="directeur">Directeurs</SelectItem>
|
||||
<SelectItem value="chef_service">Chefs de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Trier par</Label>
|
||||
<Select value={sortBy} onValueChange={(v: any) => setSortBy(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="lieu">Lieu</SelectItem>
|
||||
<SelectItem value="capacite">Capacité</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
{filteredAndSortedSequences.length} séquence(s) trouvée(s)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des séquences */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Liste des séquences
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{sequences?.length || 0} séquence(s) au total
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Chargement...</div>
|
||||
) : filteredAndSortedSequences.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune séquence trouvée
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Formation</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Dates</TableHead>
|
||||
<TableHead>Lieu</TableHead>
|
||||
<TableHead>Public cible</TableHead>
|
||||
<TableHead>Capacité</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedSequences.map((sequence) => (
|
||||
<TableRow key={sequence.id}>
|
||||
<TableCell className="font-medium">{getFormationName(sequence.formationId)}</TableCell>
|
||||
<TableCell>{sequence.nom}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
{sequence.dates.map((date: any) => (
|
||||
<div key={date.id}>
|
||||
<span className="font-medium">Date {date.ordre}:</span>{" "}
|
||||
{formatDate(date.dateDebut)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{sequence.lieu}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
|
||||
{getPublicCibleLabel(sequence.publicCible)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{sequence.capaciteMax}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(sequence.statut)}`}>
|
||||
{sequence.statut}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLocation(`/admin/sequences/${sequence.id}/inscrits`)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(sequence)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(sequence.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog d'édition */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la séquence</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de la séquence et ses dates
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-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="edit-nom">Nom de la séquence *</Label>
|
||||
<Input
|
||||
id="edit-nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-lieu">Lieu *</Label>
|
||||
<Input
|
||||
id="edit-lieu"
|
||||
value={formData.lieu}
|
||||
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-capaciteMax">Capacité maximale *</Label>
|
||||
<Input
|
||||
id="edit-capaciteMax"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={formData.capaciteMax}
|
||||
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-dateBlocage">Date de blocage (J-15) *</Label>
|
||||
<Input
|
||||
id="edit-dateBlocage"
|
||||
type="datetime-local"
|
||||
value={formData.dateBlocage}
|
||||
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-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>
|
||||
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">Dates de formation ({formData.dates.length}/4)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addDate}
|
||||
disabled={formData.dates.length >= 4}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter une date
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.dates.map((date, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">Date {date.ordre}</CardTitle>
|
||||
{formData.dates.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDate(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Début *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={date.dateDebut}
|
||||
onChange={(e) => updateDate(index, "dateDebut", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Fin *</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={date.dateFin}
|
||||
onChange={(e) => updateDate(index, "dateFin", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Modification..." : "Modifier"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
562
client/src/pages/AdminUsers.tsx
Normal file
562
client/src/pages/AdminUsers.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, Trash2, UserCheck, UserX, Search, Plus, KeyRound } from "lucide-react";
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterRole, setFilterRole] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
openId: "",
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user" as "user" | "admin",
|
||||
});
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user" as "user" | "admin",
|
||||
});
|
||||
|
||||
const { data: users = [], refetch } = trpc.users.list.useQuery();
|
||||
|
||||
const createMutation = trpc.users.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur créé avec succès");
|
||||
refetch();
|
||||
setCreateDialogOpen(false);
|
||||
setCreateForm({
|
||||
openId: "",
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.users.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur modifié avec succès");
|
||||
refetch();
|
||||
setEditDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleStatusMutation = trpc.users.toggleStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Statut modifié avec succès");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.users.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur supprimé avec succès");
|
||||
refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetPasswordMutation = trpc.users.requestPasswordReset.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Email de réinitialisation envoyé avec succès");
|
||||
setResetPasswordDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveCreate = () => {
|
||||
if (!createForm.openId) {
|
||||
toast.error("L'identifiant OpenID est obligatoire");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
openId: createForm.openId,
|
||||
name: createForm.name || undefined,
|
||||
email: createForm.email || undefined,
|
||||
role: createForm.role,
|
||||
isActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (user: any) => {
|
||||
setSelectedUser(user);
|
||||
setEditForm({
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
updateMutation.mutate({
|
||||
id: selectedUser.id,
|
||||
name: editForm.name || undefined,
|
||||
email: editForm.email || undefined,
|
||||
role: editForm.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleStatus = (user: any) => {
|
||||
toggleStatusMutation.mutate({
|
||||
id: user.id,
|
||||
isActive: !user.isActive,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (user: any) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!selectedUser) return;
|
||||
deleteMutation.mutate({ id: selectedUser.id });
|
||||
};
|
||||
|
||||
const handleResetPassword = (user: any) => {
|
||||
setSelectedUser(user);
|
||||
setResetPasswordDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmResetPassword = () => {
|
||||
if (!selectedUser) return;
|
||||
resetPasswordMutation.mutate({ id: selectedUser.id });
|
||||
};
|
||||
|
||||
// Filtrage des utilisateurs
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesRole = filterRole === "all" || user.role === filterRole;
|
||||
const matchesStatus =
|
||||
filterStatus === "all" ||
|
||||
(filterStatus === "active" && user.isActive) ||
|
||||
(filterStatus === "inactive" && !user.isActive);
|
||||
|
||||
return matchesSearch && matchesRole && matchesStatus;
|
||||
});
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return "N/A";
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-6 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Gestion des Utilisateurs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez les comptes utilisateurs de l'application
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter un utilisateur
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="mb-6 flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label htmlFor="search">Rechercher</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Nom ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<Label htmlFor="filterRole">Rôle</Label>
|
||||
<Select value={filterRole} onValueChange={setFilterRole}>
|
||||
<SelectTrigger id="filterRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les rôles</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<Label htmlFor="filterStatus">Statut</Label>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger id="filterStatus">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="active">Actif</SelectItem>
|
||||
<SelectItem value="inactive">Inactif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredUsers.length} utilisateur(s) trouvé(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tableau des utilisateurs */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Dernière connexion</TableHead>
|
||||
<TableHead>Créé le</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
Aucun utilisateur trouvé
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.id}</TableCell>
|
||||
<TableCell>{user.name || "N/A"}</TableCell>
|
||||
<TableCell>{user.email || "N/A"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? "default" : "destructive"}>
|
||||
{user.isActive ? "Actif" : "Inactif"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.lastSignedIn)}</TableCell>
|
||||
<TableCell>{formatDate(user.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(user)}
|
||||
title="Modifier"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleResetPassword(user)}
|
||||
title="Réinitialiser le mot de passe"
|
||||
disabled={!user.email}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-blue-500" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
title={user.isActive ? "Désactiver" : "Activer"}
|
||||
>
|
||||
{user.isActive ? (
|
||||
<UserX className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<UserCheck className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user)}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Dialog de création */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un nouvel utilisateur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ajoutez un nouvel utilisateur à l'application
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="createOpenId">Identifiant OpenID *</Label>
|
||||
<Input
|
||||
id="createOpenId"
|
||||
value={createForm.openId}
|
||||
onChange={(e) => setCreateForm({ ...createForm, openId: e.target.value })}
|
||||
placeholder="Identifiant unique de l'utilisateur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="createName">Nom</Label>
|
||||
<Input
|
||||
id="createName"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="createEmail">Email</Label>
|
||||
<Input
|
||||
id="createEmail"
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="createRole">Rôle</Label>
|
||||
<Select
|
||||
value={createForm.role}
|
||||
onValueChange={(value: "user" | "admin") =>
|
||||
setCreateForm({ ...createForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="createRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleSaveCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de modification */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de l'utilisateur
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="editName">Nom</Label>
|
||||
<Input
|
||||
id="editName"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editEmail">Email</Label>
|
||||
<Input
|
||||
id="editEmail"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editRole">Rôle</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value: "user" | "admin") =>
|
||||
setEditForm({ ...editForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="editRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de réinitialisation de mot de passe */}
|
||||
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Réinitialiser le mot de passe</DialogTitle>
|
||||
<DialogDescription>
|
||||
Êtes-vous sûr de vouloir envoyer un email de réinitialisation de mot de passe à{" "}
|
||||
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>ℹ️ Information :</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-blue-800 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>Un email sera envoyé à : <strong>{selectedUser?.email}</strong></li>
|
||||
<li>Le lien de réinitialisation sera valide pendant 24 heures</li>
|
||||
<li>Il ne pourra être utilisé qu'une seule fois</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmResetPassword}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
>
|
||||
{resetPasswordMutation.isPending ? "Envoi en cours..." : "Envoyer l'email"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de suppression */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
<DialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer l'utilisateur{" "}
|
||||
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
|
||||
Cette action est irréversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -881,7 +881,7 @@ export default function ComponentsShowcase() {
|
||||
<X className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your session has expired. Please log in again.
|
||||
Your sequence has expired. Please log in again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,188 @@
|
||||
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";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
useEffect(() => {
|
||||
if (!loading && isAuthenticated && user?.role === 'admin') {
|
||||
setLocation('/admin');
|
||||
}
|
||||
}, [loading, isAuthenticated, user?.role, setLocation]);
|
||||
|
||||
// Use APP_LOGO (as image src) and APP_TITLE if needed
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated && user?.role === '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 sequences 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, sequences, 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 sequences 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 sequences 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 sequence, 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 sequence 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>
|
||||
);
|
||||
}
|
||||
|
||||
432
client/src/pages/Inscription.tsx
Normal file
432
client/src/pages/Inscription.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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: "",
|
||||
fonction: "autre" as "directeur" | "chef_service" | "autre",
|
||||
});
|
||||
const [selectedSequenceId, setSelectedSequenceId] = 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: sequences, isLoading: loadingSessions } = trpc.sequences.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.sequences.listByFormation.invalidate({ formationId: formation?.id || 0 });
|
||||
utils.apprenants.list.invalidate(); // Invalider la liste des apprenants
|
||||
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 (!selectedSequenceId) {
|
||||
toast.error("Veuillez sélectionner une sequence");
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si le public cible correspond à la fonction de l'apprenant
|
||||
const selectedSequence = sequences?.find(s => s.id === selectedSequenceId);
|
||||
if (selectedSequence && selectedSequence.publicCible) {
|
||||
const publicCibleMap: Record<string, string> = {
|
||||
"directeur": "directeur",
|
||||
"chef_service": "chef_service",
|
||||
"autre": "autre"
|
||||
};
|
||||
|
||||
if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) {
|
||||
const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" :
|
||||
selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Autre";
|
||||
const fonctionLabel = formData.fonction === "directeur" ? "Directeur" :
|
||||
formData.fonction === "chef_service" ? "Chef de service" : "Autre";
|
||||
|
||||
toast.info(
|
||||
`Information : Cette séquence est destinée aux ${publicCibleLabel}, mais vous êtes inscrit(e) en tant que ${fonctionLabel}. Vous pouvez tout de même vous inscrire.`,
|
||||
{ duration: 8000 }
|
||||
);
|
||||
|
||||
// Attendre 2 secondes pour que l'utilisateur puisse lire l'alerte
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
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 sequence
|
||||
await inscriptionMutation.mutateAsync({
|
||||
apprenantId,
|
||||
sequenceId: selectedSequenceId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur lors de l'inscription:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer les places disponibles pour chaque sequence
|
||||
const sessionsAvecPlaces = useMemo(() => {
|
||||
if (!sequences) return [];
|
||||
|
||||
return sequences.map(sequence => {
|
||||
const now = new Date();
|
||||
const dateBlocage = new Date(sequence.dateBlocage);
|
||||
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
|
||||
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits || 0));
|
||||
|
||||
return {
|
||||
...sequence,
|
||||
isBloquee,
|
||||
placesRestantes,
|
||||
};
|
||||
});
|
||||
}, [sequences]);
|
||||
|
||||
const formatDate = (dateValue: Date | string) => {
|
||||
if (!dateValue) return "N/A";
|
||||
|
||||
try {
|
||||
let localDate: Date;
|
||||
|
||||
// Si c'est déjà un objet Date JavaScript, l'utiliser directement
|
||||
if (dateValue instanceof Date) {
|
||||
localDate = dateValue;
|
||||
} else {
|
||||
// Parser la date MySQL sans conversion UTC
|
||||
const dateStr = String(dateValue);
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
console.warn('Format de date non reconnu:', dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
|
||||
// Créer une Date en heure locale
|
||||
localDate = new Date(
|
||||
parseInt(year),
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hours),
|
||||
parseInt(minutes)
|
||||
);
|
||||
}
|
||||
|
||||
return format(localDate, "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
} catch (error) {
|
||||
console.error('Erreur de formatage de date:', error, dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
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 sequence pour vous inscrire (capacité : 12 participants maximum)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessionsAvecPlaces.map((sequence) => (
|
||||
<div
|
||||
key={sequence.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedSequenceId === sequence.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: sequence.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={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3 className="font-semibold text-lg">{sequence.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" />
|
||||
<div className="space-y-0.5">
|
||||
{sequence.dates.map((date: any) => (
|
||||
<div key={date.id}>
|
||||
<span className="font-medium">Date {date.ordre}:</span> {formatDate(date.dateDebut)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{sequence.lieu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{sequence.placesRestantes} places disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{sequence.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>
|
||||
) : sequence.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 sequence disponible pour le moment.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Formulaire d'inscription */}
|
||||
{selectedSequenceId && (
|
||||
<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="space-y-2">
|
||||
<Label htmlFor="fonction">Fonction *</Label>
|
||||
<Select value={formData.fonction} onValueChange={(value: any) => setFormData({ ...formData, fonction: value })}>
|
||||
<SelectTrigger id="fonction">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 sequence.
|
||||
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