Files
formation-manager-itinova/client/src/pages/AdminFormations.tsx

343 lines
13 KiB
TypeScript

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";
import { useAuth } from "@/_core/hooks/useAuth";
export default function AdminFormations() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
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="page-title">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();
}}>
{isAdmin && (
<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, index) => (
<TableRow key={formation.id} className={index % 2 === 1 ? "bg-blue-50/70" : ""}>
<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">
{isAdmin && (
<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-red-600" />
</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>
);
}