diff --git a/client/src/components/CapacityProgressBar.tsx b/client/src/components/CapacityProgressBar.tsx new file mode 100644 index 0000000..95002c9 --- /dev/null +++ b/client/src/components/CapacityProgressBar.tsx @@ -0,0 +1,39 @@ +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; + +interface CapacityProgressBarProps { + nbInscrits: number; + capaciteMax: number; +} + +export default function CapacityProgressBar({ + nbInscrits, + capaciteMax, +}: CapacityProgressBarProps) { + const percentage = capaciteMax > 0 ? Math.round((nbInscrits / capaciteMax) * 100) : 0; + + // Déterminer la couleur selon le taux de remplissage + const getColor = () => { + if (percentage < 70) return "bg-green-500"; + if (percentage < 90) return "bg-orange-500"; + return "bg-red-500"; + }; + + return ( +
+
+ + {nbInscrits} / {capaciteMax} + + + {percentage}% + +
+ +
+ ); +} diff --git a/client/src/pages/AdminCalendrier.tsx b/client/src/pages/AdminCalendrier.tsx new file mode 100644 index 0000000..8700f98 --- /dev/null +++ b/client/src/pages/AdminCalendrier.tsx @@ -0,0 +1,254 @@ +import { useState } 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, +} from "@/components/ui/dialog"; +import { trpc } from "@/lib/trpc"; +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, parseISO, addMonths, subMonths } from "date-fns"; +import { fr } from "date-fns/locale"; + +export default function AdminCalendrier() { + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedSequence, setSelectedSequence] = useState(null); + const [isDetailOpen, setIsDetailOpen] = useState(false); + + const { data: sequences, isLoading } = trpc.sequences.list.useQuery(); + + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + // Obtenir le premier jour de la semaine (lundi = 0) + const firstDayOfWeek = (monthStart.getDay() + 6) % 7; + + // Créer un tableau avec les jours vides au début + const calendarDays = [ + ...Array(firstDayOfWeek).fill(null), + ...daysInMonth, + ]; + + const getSequencesForDay = (day: Date) => { + if (!sequences) return []; + return sequences.filter((seq) => { + if (!seq.datesFormation || seq.datesFormation.length === 0) return false; + return seq.datesFormation.some((dateForm: any) => { + const dateDebut = parseISO(dateForm.dateDebut); + return isSameDay(dateDebut, day); + }); + }); + }; + + const handlePreviousMonth = () => { + setCurrentDate(subMonths(currentDate, 1)); + }; + + const handleNextMonth = () => { + setCurrentDate(addMonths(currentDate, 1)); + }; + + const handleSequenceClick = (sequence: any) => { + setSelectedSequence(sequence); + setIsDetailOpen(true); + }; + + const getPublicCibleLabel = (publicCible: string) => { + const labels: Record = { + directeurs: "Directeurs", + chefs_service: "Chefs de service", + tous: "Tous", + autre: "Autre", + }; + return labels[publicCible] || publicCible; + }; + + const getStatutBadge = (statut: string) => { + const badges: Record = { + ouverte: "bg-green-100 text-green-800", + fermee: "bg-red-100 text-red-800", + annulee: "bg-gray-100 text-gray-800", + }; + return badges[statut] || "bg-blue-100 text-blue-800"; + }; + + return ( + +
+
+

Calendrier des séquences

+

+ Vue mensuelle de toutes les séquences planifiées +

+
+ + + +
+
+ + + {format(currentDate, "MMMM yyyy", { locale: fr })} + + + Cliquez sur une séquence pour voir les détails + +
+
+ + + +
+
+
+ + {isLoading ? ( +
Chargement...
+ ) : ( +
+ {/* En-têtes des jours */} + {["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => ( +
+ {day} +
+ ))} + + {/* Jours du calendrier */} + {calendarDays.map((day, index) => { + if (!day) { + return
; + } + + const daySequences = getSequencesForDay(day); + const isToday = isSameDay(day, new Date()); + + return ( +
+
+ {format(day, "d")} +
+
+ {daySequences.map((seq) => ( + + ))} +
+
+ ); + })} +
+ )} + + + + {/* Dialog de détails */} + + + + Détails de la séquence + + Informations complètes sur la séquence sélectionnée + + + {selectedSequence && ( +
+
+

+ {selectedSequence.formation?.titre || "Sans titre"} +

+

+ {selectedSequence.formation?.description || "Aucune description"} +

+
+ +
+
+

Lieu

+

{selectedSequence.lieu}

+
+
+

Public cible

+ + {getPublicCibleLabel(selectedSequence.publicCible)} + +
+
+

Capacité

+

+ {selectedSequence.nbInscrits || 0} / {selectedSequence.capaciteMax} +

+
+
+

Statut

+ + {selectedSequence.statut} + +
+ {selectedSequence.formateur && ( +
+

Formateur

+

{selectedSequence.formateur}

+
+ )} + {selectedSequence.dateBloquage && ( +
+

Date de blocage

+

+ {format(parseISO(selectedSequence.dateBloquage), "dd/MM/yyyy", { locale: fr })} +

+
+ )} +
+ + {selectedSequence.datesFormation && selectedSequence.datesFormation.length > 0 && ( +
+

Dates de formation

+
+ {selectedSequence.datesFormation.map((dateForm: any, index: number) => ( +
+ Du {format(parseISO(dateForm.dateDebut), "dd/MM/yyyy", { locale: fr })} au{" "} + {format(parseISO(dateForm.dateFin), "dd/MM/yyyy", { locale: fr })} +
+ ))} +
+
+ )} +
+ )} +
+
+
+ + ); +} diff --git a/client/src/pages/AdminRappels.tsx b/client/src/pages/AdminRappels.tsx new file mode 100644 index 0000000..8d46119 --- /dev/null +++ b/client/src/pages/AdminRappels.tsx @@ -0,0 +1,366 @@ +import { useState } 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, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { trpc } from "@/lib/trpc"; +import { Bell, Edit, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +export default function AdminRappels() { + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [editingRappel, setEditingRappel] = useState(null); + const [formData, setFormData] = useState({ + nom: "", + joursAvant: 0, + sujet: "", + contenu: "", + actif: true, + }); + + const { data: rappels, refetch } = trpc.rappels.list.useQuery(); + const createMutation = trpc.rappels.create.useMutation({ + onSuccess: () => { + toast.success("Rappel créé avec succès"); + refetch(); + setIsCreateOpen(false); + resetForm(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + + const updateMutation = trpc.rappels.update.useMutation({ + onSuccess: () => { + toast.success("Rappel modifié avec succès"); + refetch(); + setIsEditOpen(false); + setEditingRappel(null); + resetForm(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + + const deleteMutation = trpc.rappels.delete.useMutation({ + onSuccess: () => { + toast.success("Rappel supprimé avec succès"); + refetch(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + + const resetForm = () => { + setFormData({ + nom: "", + joursAvant: 0, + sujet: "", + contenu: "", + actif: true, + }); + }; + + const handleCreate = () => { + if (!formData.nom || !formData.sujet || !formData.contenu) { + toast.error("Veuillez remplir tous les champs obligatoires"); + return; + } + createMutation.mutate(formData); + }; + + const handleEdit = (rappel: any) => { + setEditingRappel(rappel); + setFormData({ + nom: rappel.nom, + joursAvant: rappel.joursAvant, + sujet: rappel.sujet, + contenu: rappel.contenu, + actif: rappel.actif, + }); + setIsEditOpen(true); + }; + + const handleUpdate = () => { + if (!editingRappel) return; + if (!formData.nom || !formData.sujet || !formData.contenu) { + toast.error("Veuillez remplir tous les champs obligatoires"); + return; + } + updateMutation.mutate({ + id: editingRappel.id, + ...formData, + }); + }; + + const handleDelete = (id: number) => { + if (confirm("Êtes-vous sûr de vouloir supprimer ce rappel ?")) { + deleteMutation.mutate({ id }); + } + }; + + return ( + +
+
+
+

Rappels automatiques

+

+ Configurez les rappels envoyés automatiquement avant les séquences +

+
+ +
+ + + + + + Liste des rappels configurés + + + Les rappels actifs seront envoyés automatiquement aux apprenants inscrits + + + + + + + Nom + Délai + Sujet + Statut + Actions + + + + {rappels && rappels.length > 0 ? ( + rappels.map((rappel) => ( + + {rappel.nom} + J-{rappel.joursAvant} + {rappel.sujet} + + + {rappel.actif ? "Actif" : "Inactif"} + + + +
+ + +
+
+
+ )) + ) : ( + + + Aucun rappel configuré + + + )} +
+
+
+
+ + {/* Dialog de création */} + + + + Créer un nouveau rappel + + Configurez un rappel automatique à envoyer avant les séquences + + +
+
+ + setFormData({ ...formData, nom: e.target.value })} + placeholder="Ex: Rappel J-15" + /> +
+ +
+ + + setFormData({ ...formData, joursAvant: parseInt(e.target.value) || 0 }) + } + placeholder="Ex: 15" + /> +

+ Le rappel sera envoyé ce nombre de jours avant le début de la séquence +

+
+ +
+ + setFormData({ ...formData, sujet: e.target.value })} + placeholder="Ex: Rappel - Formation dans 15 jours" + /> +
+ +
+ +