Checkpoint: Refonte majeure : transformation du système de sessions en séquences avec support de jusqu'à 4 dates par séquence. L'inscription à une séquence inscrit automatiquement l'apprenant à toutes les dates. Mise à jour complète du backend (schéma DB, procédures tRPC, emails, exports) et du frontend (AdminSequences, AdminSequenceInscrits, Inscription, Admin). Toutes les fonctionnalités existantes (filtres, statistiques, exports PDF/Excel, emails automatiques) sont préservées et adaptées au nouveau modèle.
This commit is contained in:
@@ -8,7 +8,7 @@ import { useMemo } from "react";
|
||||
|
||||
export default function Admin() {
|
||||
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
|
||||
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery();
|
||||
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.list.useQuery();
|
||||
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
|
||||
|
||||
// Statistiques par fonction
|
||||
@@ -34,8 +34,8 @@ export default function Admin() {
|
||||
loading: loadingFormations,
|
||||
},
|
||||
{
|
||||
title: "Sessions ouvertes",
|
||||
value: sessions?.filter(s => s.statut === "ouverte").length || 0,
|
||||
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,
|
||||
},
|
||||
@@ -46,8 +46,8 @@ export default function Admin() {
|
||||
loading: loadingApprenants,
|
||||
},
|
||||
{
|
||||
title: "Sessions terminées",
|
||||
value: sessions?.filter(s => s.statut === "terminee").length || 0,
|
||||
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,
|
||||
},
|
||||
@@ -132,7 +132,7 @@ export default function Admin() {
|
||||
<CardHeader>
|
||||
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos formations, sessions et apprenants depuis le menu de navigation.
|
||||
Gérez vos formations, sequences et apprenants depuis le menu de navigation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -140,7 +140,7 @@ export default function Admin() {
|
||||
<h3 className="font-semibold">Fonctionnalités principales :</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Créer et gérer les formations avec liens d'inscription uniques</li>
|
||||
<li>Organiser les sessions avec limitation à 12 participants</li>
|
||||
<li>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>
|
||||
|
||||
512
client/src/pages/AdminSequenceInscrits.tsx
Normal file
512
client/src/pages/AdminSequenceInscrits.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
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;
|
||||
|
||||
// É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 = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
731
client/src/pages/AdminSequences.tsx
Normal file
731
client/src/pages/AdminSequences.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
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 [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date");
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
lieu: "",
|
||||
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: () => {
|
||||
toast.success("Séquence modifiée avec succès");
|
||||
setIsEditOpen(false);
|
||||
setEditingSequence(null);
|
||||
utils.sequences.list.invalidate();
|
||||
},
|
||||
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: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte",
|
||||
dates: [{ dateDebut: "", dateFin: "", ordre: 1 }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (sequence: any) => {
|
||||
setEditingSequence(sequence);
|
||||
setFormData({
|
||||
formationId: sequence.formationId.toString(),
|
||||
nom: sequence.nom,
|
||||
lieu: sequence.lieu,
|
||||
capaciteMax: sequence.capaciteMax.toString(),
|
||||
dateBlocage: format(new Date(sequence.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
|
||||
statut: sequence.statut,
|
||||
dates: sequence.dates.map((d: any) => ({
|
||||
dateDebut: format(new Date(d.dateDebut), "yyyy-MM-dd'T'HH:mm"),
|
||||
dateFin: format(new Date(d.dateFin), "yyyy-MM-dd'T'HH:mm"),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
});
|
||||
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,
|
||||
capaciteMax: parseInt(formData.capaciteMax),
|
||||
dateBlocage: formData.dateBlocage,
|
||||
statut: formData.statut,
|
||||
dates: formData.dates,
|
||||
};
|
||||
|
||||
if (editingSequence) {
|
||||
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;
|
||||
return matchSearch && matchLieu && matchStatut;
|
||||
});
|
||||
|
||||
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, 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 = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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="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>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>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>{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>
|
||||
);
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
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, SlidersHorizontal } 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 AdminSessionInscrits() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [, params] = useRoute("/admin/sessions/:id/inscrits");
|
||||
const sessionId = params?.id ? parseInt(params.id) : 0;
|
||||
|
||||
// É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: session, isLoading: loadingSession } = trpc.sessions.getById.useQuery({ id: sessionId });
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySession.useQuery({ sessionId });
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inscriptions.listBySession.invalidate({ sessionId });
|
||||
toast.success("Statut mis à jour avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmailsMutation = trpc.inscriptions.sendGroupEmails.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'envoi : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatutChange = (inscriptionId: number, newStatut: string) => {
|
||||
updateStatutMutation.mutate({
|
||||
id: inscriptionId,
|
||||
statut: newStatut as "confirmee" | "liste_attente" | "annulee",
|
||||
});
|
||||
};
|
||||
|
||||
const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export Excel téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inscrits-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export PDF téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `feuille-presence-session-${sessionId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Feuille de présence téléchargée");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la génération : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = () => {
|
||||
exportExcelMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleExportPDF = () => {
|
||||
exportPDFMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const handleGenerateFeuillePresence = () => {
|
||||
exportFeuilleMutation.mutate({ sessionId });
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
// Extraire les codes établissement uniques
|
||||
const uniqueEtablissements = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
const codes = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => i.apprenant!.codeEtablissement);
|
||||
return Array.from(new Set(codes)).sort();
|
||||
}, [inscriptions]);
|
||||
|
||||
// Filtrer et trier les inscriptions
|
||||
const filteredAndSortedInscriptions = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
|
||||
let filtered = inscriptions.filter(inscription => {
|
||||
// Filtre par recherche (nom, prénom, email)
|
||||
const matchesSearch = searchTerm === "" ||
|
||||
inscription.apprenant?.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inscription.apprenant?.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inscription.apprenant?.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Filtre par statut
|
||||
const matchesStatut = filterStatut === "all" || inscription.inscription.statut === filterStatut;
|
||||
|
||||
// Filtre par établissement
|
||||
const matchesEtablissement = filterEtablissement === "all" ||
|
||||
inscription.apprenant?.codeEtablissement === filterEtablissement;
|
||||
|
||||
// Filtre par fonction
|
||||
const matchesFonction = filterFonction === "all" ||
|
||||
inscription.apprenant?.fonction === filterFonction;
|
||||
|
||||
return matchesSearch && matchesStatut && matchesEtablissement && matchesFonction;
|
||||
});
|
||||
|
||||
// Tri
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "date") {
|
||||
return new Date(b.inscription.dateInscription).getTime() - new Date(a.inscription.dateInscription).getTime();
|
||||
} else if (sortBy === "nom") {
|
||||
return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || "");
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]);
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
confirmee: "bg-green-100 text-green-800",
|
||||
liste_attente: "bg-orange-100 text-orange-800",
|
||||
annulee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
confirmee: "Confirmée",
|
||||
liste_attente: "Liste d'attente",
|
||||
annulee: "Annulée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const inscritsConfirmes = inscriptions?.filter(i => i.inscription.statut === "confirmee") || [];
|
||||
const listeAttente = inscriptions?.filter(i => i.inscription.statut === "liste_attente") || [];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setLocation("/admin/sessions")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Inscrits à la session</h1>
|
||||
{session && (
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{getFormationName(session.formationId)} - {session.nom}
|
||||
</p>
|
||||
)} </div>
|
||||
</div>
|
||||
|
||||
{/* 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 lg:grid-cols-5 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, prénom ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtre par statut */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterStatut">Statut</Label>
|
||||
<Select value={filterStatut} onValueChange={setFilterStatut}>
|
||||
<SelectTrigger id="filterStatut">
|
||||
<SelectValue placeholder="Tous les statuts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtre par établissement */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterEtablissement">Établissement</Label>
|
||||
<Select value={filterEtablissement} onValueChange={setFilterEtablissement}>
|
||||
<SelectTrigger id="filterEtablissement">
|
||||
<SelectValue placeholder="Tous les établissements" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les établissements</SelectItem>
|
||||
{uniqueEtablissements.map(code => (
|
||||
<SelectItem key={code} value={code}>{code}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtre par fonction */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterFonction">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>
|
||||
|
||||
{/* Tri */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortBy">Trier par</Label>
|
||||
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
||||
<SelectTrigger id="sortBy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date d'inscription</SelectItem>
|
||||
<SelectItem value="nom">Nom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{filteredAndSortedInscriptions.length} inscrit(s) trouvé(s) sur {inscriptions?.length || 0} total
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loadingSession || loadingInscriptions ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : session ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Places confirmées</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{inscritsConfirmes.length} / {session.capaciteMax}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{session.capaciteMax - inscritsConfirmes.length} places restantes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Liste d'attente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{listeAttente.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
En attente de places
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Total inscriptions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{inscriptions?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Toutes statuts confondus
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExportExcel}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportPDF}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGenerateFeuillePresence}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Feuille de présence
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'teaser' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer teaser
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'rappel' })}
|
||||
disabled={sendEmailsMutation.isPending}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Envoyer rappel J-7
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les inscriptions et leurs statuts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredAndSortedInscriptions && filteredAndSortedInscriptions.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code établissement</TableHead>
|
||||
<TableHead>Date d'inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedInscriptions.map((item) => (
|
||||
<TableRow key={item.inscription.id}>
|
||||
<TableCell className="font-medium">{item.apprenant?.nom}</TableCell>
|
||||
<TableCell>{item.apprenant?.prenom}</TableCell>
|
||||
<TableCell>{item.apprenant?.email}</TableCell>
|
||||
<TableCell>{item.apprenant?.codeEtablissement}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(item.inscription.dateInscription).toLocaleDateString('fr-FR')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatutBadge(item.inscription.statut)}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.inscription.statut}
|
||||
onValueChange={(value) => handleStatutChange(item.inscription.id, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune inscription pour cette session.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Session introuvable.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Plus, Pencil, Trash2, Users as UsersIcon, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export default function AdminSessions() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterLieu, setFilterLieu] = useState<string>("all");
|
||||
const [filterStatut, setFilterStatut] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<"date" | "lieu" | "places">("date");
|
||||
const [formData, setFormData] = useState({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sessions, isLoading } = trpc.sessions.list.useQuery();
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const createMutation = trpc.sessions.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session créée avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la création : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = trpc.sessions.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session mise à jour avec succès");
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.sessions.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sessions.list.invalidate();
|
||||
toast.success("Session supprimée avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la suppression : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
formationId: "",
|
||||
nom: "",
|
||||
dateDebut: "",
|
||||
dateFin: "",
|
||||
lieu: "",
|
||||
capaciteMax: "12",
|
||||
dateBlocage: "",
|
||||
statut: "ouverte",
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (session: any) => {
|
||||
setFormData({
|
||||
formationId: session.formationId.toString(),
|
||||
nom: session.nom,
|
||||
dateDebut: format(new Date(session.dateDebut), "yyyy-MM-dd'T'HH:mm"),
|
||||
dateFin: format(new Date(session.dateFin), "yyyy-MM-dd'T'HH:mm"),
|
||||
lieu: session.lieu,
|
||||
capaciteMax: session.capaciteMax.toString(),
|
||||
dateBlocage: format(new Date(session.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
|
||||
statut: session.statut,
|
||||
});
|
||||
setEditingId(session.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
formationId: parseInt(formData.formationId),
|
||||
nom: formData.nom,
|
||||
dateDebut: formData.dateDebut,
|
||||
dateFin: formData.dateFin,
|
||||
lieu: formData.lieu,
|
||||
capaciteMax: parseInt(formData.capaciteMax),
|
||||
dateBlocage: formData.dateBlocage,
|
||||
statut: formData.statut,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm("Êtes-vous sûr de vouloir supprimer cette session ?")) {
|
||||
deleteMutation.mutate({ id });
|
||||
}
|
||||
};
|
||||
|
||||
const getFormationName = (formationId: number) => {
|
||||
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
|
||||
};
|
||||
|
||||
// Extraire les lieux uniques pour le filtre
|
||||
const uniqueLieux = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
const lieux = sessions.map(s => s.lieu);
|
||||
return Array.from(new Set(lieux)).sort();
|
||||
}, [sessions]);
|
||||
|
||||
// Filtrer et trier les sessions
|
||||
const filteredAndSortedSessions = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
|
||||
let filtered = sessions.filter(session => {
|
||||
// Filtre par recherche (nom de session ou formation)
|
||||
const matchesSearch = searchTerm === "" ||
|
||||
session.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
getFormationName(session.formationId).toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Filtre par lieu
|
||||
const matchesLieu = filterLieu === "all" || session.lieu === filterLieu;
|
||||
|
||||
// Filtre par statut
|
||||
const matchesStatut = filterStatut === "all" || session.statut === filterStatut;
|
||||
|
||||
return matchesSearch && matchesLieu && matchesStatut;
|
||||
});
|
||||
|
||||
// Tri
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "date") {
|
||||
return new Date(a.dateDebut).getTime() - new Date(b.dateDebut).getTime();
|
||||
} else if (sortBy === "lieu") {
|
||||
return a.lieu.localeCompare(b.lieu);
|
||||
} else if (sortBy === "places") {
|
||||
return a.capaciteMax - b.capaciteMax;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [sessions, searchTerm, filterLieu, filterStatut, sortBy, formations]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr });
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const styles = {
|
||||
ouverte: "bg-green-100 text-green-800",
|
||||
bloquee: "bg-orange-100 text-orange-800",
|
||||
terminee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
ouverte: "Ouverte",
|
||||
bloquee: "Bloquée",
|
||||
terminee: "Terminée",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
|
||||
{labels[statut as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les sessions de formation
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle session
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingId ? "Modifier la session" : "Nouvelle session"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Créez une session avec dates, lieu et capacité
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formationId">Formation *</Label>
|
||||
<Select
|
||||
value={formData.formationId}
|
||||
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionnez une formation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formations?.map((formation) => (
|
||||
<SelectItem key={formation.id} value={formation.id.toString()}>
|
||||
{formation.nom}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nom">Nom de la session *</Label>
|
||||
<Input
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
||||
placeholder="Session 1 - Février 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateDebut">Date de début *</Label>
|
||||
<Input
|
||||
id="dateDebut"
|
||||
type="datetime-local"
|
||||
value={formData.dateDebut}
|
||||
onChange={(e) => setFormData({ ...formData, dateDebut: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateFin">Date de fin *</Label>
|
||||
<Input
|
||||
id="dateFin"
|
||||
type="datetime-local"
|
||||
value={formData.dateFin}
|
||||
onChange={(e) => setFormData({ ...formData, dateFin: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lieu">Lieu *</Label>
|
||||
<Input
|
||||
id="lieu"
|
||||
value={formData.lieu}
|
||||
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
|
||||
placeholder="Salle de formation A, Bâtiment principal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
|
||||
<Input
|
||||
id="capaciteMax"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capaciteMax}
|
||||
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateBlocage">Date de blocage (J-15) *</Label>
|
||||
<Input
|
||||
id="dateBlocage"
|
||||
type="datetime-local"
|
||||
value={formData.dateBlocage}
|
||||
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="statut">Statut</Label>
|
||||
<Select
|
||||
value={formData.statut}
|
||||
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ouverte">Ouverte</SelectItem>
|
||||
<SelectItem value="bloquee">Bloquée</SelectItem>
|
||||
<SelectItem value="terminee">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingId ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 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 lg:grid-cols-4 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 de session ou formation..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtre par lieu */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterLieu">Lieu</Label>
|
||||
<Select value={filterLieu} onValueChange={setFilterLieu}>
|
||||
<SelectTrigger id="filterLieu">
|
||||
<SelectValue placeholder="Tous les lieux" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les lieux</SelectItem>
|
||||
{uniqueLieux.map(lieu => (
|
||||
<SelectItem key={lieu} value={lieu}>{lieu}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtre par statut */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filterStatut">Statut</Label>
|
||||
<Select value={filterStatut} onValueChange={setFilterStatut}>
|
||||
<SelectTrigger id="filterStatut">
|
||||
<SelectValue placeholder="Tous les statuts" />
|
||||
</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>
|
||||
|
||||
{/* Tri */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortBy">Trier par</Label>
|
||||
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
||||
<SelectTrigger id="sortBy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
<SelectItem value="lieu">Lieu</SelectItem>
|
||||
<SelectItem value="places">Capacité</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
{filteredAndSortedSessions.length} session(s) trouvée(s)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez vos sessions et consultez les inscriptions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : filteredAndSortedSessions && filteredAndSortedSessions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Formation</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Date début</TableHead>
|
||||
<TableHead>Date fin</TableHead>
|
||||
<TableHead>Lieu</TableHead>
|
||||
<TableHead>Capacité</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedSessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell>
|
||||
<TableCell>{session.nom}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateDebut)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(session.dateFin)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{session.lieu}</TableCell>
|
||||
<TableCell>{session.capaciteMax}</TableCell>
|
||||
<TableCell>{getStatutBadge(session.statut)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation(`/admin/sessions/${session.id}/inscrits`)}
|
||||
title="Voir les inscrits"
|
||||
>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(session)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session pour le moment. Créez-en une pour commencer.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Home() {
|
||||
{
|
||||
icon: <Calendar className="w-8 h-8 text-green-600" />,
|
||||
title: "Sessions planifiées",
|
||||
description: "Organisez des sessions avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.",
|
||||
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" />,
|
||||
@@ -80,7 +80,7 @@ export default function Home() {
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Solution complète pour gérer l'organisation interne des formations destinées à environ 150 apprenants.
|
||||
Inscriptions, sessions, communications automatisées et exports simplifiés.
|
||||
Inscriptions, sequences, communications automatisées et exports simplifiés.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{isAuthenticated && user?.role === 'admin' && (
|
||||
@@ -132,7 +132,7 @@ export default function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Créez des formations et des sessions avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.</p>
|
||||
<p>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>
|
||||
@@ -145,7 +145,7 @@ export default function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Cliquez sur le lien d'inscription fourni par la DRH, consultez les sessions disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.</p>
|
||||
<p>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>
|
||||
@@ -158,8 +158,8 @@ export default function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par session, prévention des doubles inscriptions, et gestion de la liste d'attente.</p>
|
||||
<p>Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.</p>
|
||||
<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>
|
||||
|
||||
@@ -24,12 +24,12 @@ export default function Inscription() {
|
||||
codeEtablissement: "",
|
||||
fonction: "autre" as "directeur" | "chef_service" | "autre",
|
||||
});
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<number | null>(null);
|
||||
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: sessions, isLoading: loadingSessions } = trpc.sessions.listByFormation.useQuery(
|
||||
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.listByFormation.useQuery(
|
||||
{ formationId: formation?.id || 0 },
|
||||
{ enabled: !!formation?.id }
|
||||
);
|
||||
@@ -39,7 +39,7 @@ export default function Inscription() {
|
||||
onSuccess: (data) => {
|
||||
setInscriptionSuccess(true);
|
||||
setInscriptionStatut(data.statut);
|
||||
utils.sessions.listByFormation.invalidate({ formationId: formation?.id || 0 });
|
||||
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 !");
|
||||
@@ -57,8 +57,8 @@ export default function Inscription() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedSessionId) {
|
||||
toast.error("Veuillez sélectionner une session");
|
||||
if (!selectedSequenceId) {
|
||||
toast.error("Veuillez sélectionner une sequence");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,32 +81,32 @@ export default function Inscription() {
|
||||
apprenantId = newApprenant.id;
|
||||
}
|
||||
|
||||
// Inscrire l'apprenant à la session
|
||||
// Inscrire l'apprenant à la sequence
|
||||
await inscriptionMutation.mutateAsync({
|
||||
apprenantId,
|
||||
sessionId: selectedSessionId,
|
||||
sequenceId: selectedSequenceId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur lors de l'inscription:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer les places disponibles pour chaque session
|
||||
// Calculer les places disponibles pour chaque sequence
|
||||
const sessionsAvecPlaces = useMemo(() => {
|
||||
if (!sessions) return [];
|
||||
if (!sequences) return [];
|
||||
|
||||
return sessions.map(session => {
|
||||
return sequences.map(sequence => {
|
||||
const now = new Date();
|
||||
const dateBlocage = new Date(session.dateBlocage);
|
||||
const isBloquee = session.statut === 'bloquee' || now >= dateBlocage;
|
||||
const dateBlocage = new Date(sequence.dateBlocage);
|
||||
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
|
||||
|
||||
return {
|
||||
...session,
|
||||
...sequence,
|
||||
isBloquee,
|
||||
placesRestantes: session.capaciteMax,
|
||||
placesRestantes: sequence.capaciteMax,
|
||||
};
|
||||
});
|
||||
}, [sessions]);
|
||||
}, [sequences]);
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
@@ -202,49 +202,55 @@ export default function Inscription() {
|
||||
<CardHeader>
|
||||
<CardTitle>Sessions disponibles</CardTitle>
|
||||
<CardDescription>
|
||||
Sélectionnez une session pour vous inscrire (capacité : 12 participants maximum)
|
||||
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((session) => (
|
||||
{sessionsAvecPlaces.map((sequence) => (
|
||||
<div
|
||||
key={session.id}
|
||||
key={sequence.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
selectedSessionId === session.id
|
||||
selectedSequenceId === sequence.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: session.isBloquee
|
||||
: 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={() => !session.isBloquee && setSelectedSessionId(session.id)}
|
||||
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">{session.nom}</h3>
|
||||
<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" />
|
||||
<span>{formatDate(session.dateDebut)}</span>
|
||||
<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>{session.lieu}</span>
|
||||
<span>{sequence.lieu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{session.placesRestantes} places disponibles</span>
|
||||
<span>{sequence.placesRestantes} places disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{session.isBloquee ? (
|
||||
{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>
|
||||
) : session.placesRestantes === 0 ? (
|
||||
) : 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
|
||||
@@ -262,14 +268,14 @@ export default function Inscription() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune session disponible pour le moment.
|
||||
Aucune sequence disponible pour le moment.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Formulaire d'inscription */}
|
||||
{selectedSessionId && (
|
||||
{selectedSequenceId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vos informations</CardTitle>
|
||||
@@ -338,7 +344,7 @@ export default function Inscription() {
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session.
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user