Checkpoint: Checkpoint complet avec toutes les fonctionnalités développées

 **Fonctionnalités incluses**

**1. Gestion des formateurs**
- Table formateurs dans la base de données
- Composant FormateurCombobox avec autocomplétion
- Ajout rapide de nouveaux formateurs depuis le formulaire
- Champ formateur dans les séquences (création et modification)
- Affichage du formateur dans le tableau et les emails

**2. Barre de progression colorée**
- Composant CapacityProgressBar avec code couleur automatique
- Vert (<70%), Orange (70-90%), Rouge (>90%)
- Affichage "nbInscrits / capacité" + pourcentage
- Intégré dans le tableau des séquences

**3. Vue calendrier mensuel**
- Page AdminCalendrier avec navigation mois précédent/suivant
- Affichage des séquences sur leurs dates respectives
- Dialog de détails au clic sur une séquence
- Lien "Calendrier" dans le menu de navigation

**4. Système de rappels automatiques**
- Page AdminRappels pour configurer les rappels (J-15, J-7, J-1)
- Templates d'emails personnalisables avec variables dynamiques
- Activation/désactivation des rappels
- Infrastructure prête pour l'envoi automatique futur

**5. Améliorations diverses**
- Public cible "Tous" ajouté dans les options
- Formulaires de séquences réorganisés (pas de chevauchement)
- Affichage du formateur dans les emails d'invitation
- Colonne formateur dans le tableau des séquences
This commit is contained in:
Manus Sandbox
2025-11-19 11:20:40 -05:00
parent b7cadc173b
commit 21b75d4464
5 changed files with 676 additions and 1 deletions

View File

@@ -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<any>(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<string, string> = {
directeurs: "Directeurs",
chefs_service: "Chefs de service",
tous: "Tous",
autre: "Autre",
};
return labels[publicCible] || publicCible;
};
const getStatutBadge = (statut: string) => {
const badges: Record<string, string> = {
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 (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Calendrier des séquences</h1>
<p className="text-muted-foreground">
Vue mensuelle de toutes les séquences planifiées
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
{format(currentDate, "MMMM yyyy", { locale: fr })}
</CardTitle>
<CardDescription>
Cliquez sur une séquence pour voir les détails
</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={handlePreviousMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setCurrentDate(new Date())}
>
Aujourd'hui
</Button>
<Button variant="outline" size="icon" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Chargement...</div>
) : (
<div className="grid grid-cols-7 gap-2">
{/* En-têtes des jours */}
{["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].map((day) => (
<div
key={day}
className="text-center font-semibold text-sm text-muted-foreground py-2"
>
{day}
</div>
))}
{/* Jours du calendrier */}
{calendarDays.map((day, index) => {
if (!day) {
return <div key={`empty-${index}`} className="min-h-[100px]" />;
}
const daySequences = getSequencesForDay(day);
const isToday = isSameDay(day, new Date());
return (
<div
key={day.toISOString()}
className={`min-h-[100px] border rounded-lg p-2 ${
!isSameMonth(day, currentDate)
? "bg-muted/50"
: isToday
? "bg-blue-50 border-blue-300"
: "bg-background"
}`}
>
<div className="font-semibold text-sm mb-1">
{format(day, "d")}
</div>
<div className="space-y-1">
{daySequences.map((seq) => (
<button
key={seq.id}
onClick={() => handleSequenceClick(seq)}
className="w-full text-left text-xs p-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-800 truncate"
>
{seq.formation?.titre || "Sans titre"}
</button>
))}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Dialog de détails */}
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Détails de la séquence</DialogTitle>
<DialogDescription>
Informations complètes sur la séquence sélectionnée
</DialogDescription>
</DialogHeader>
{selectedSequence && (
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg">
{selectedSequence.formation?.titre || "Sans titre"}
</h3>
<p className="text-sm text-muted-foreground">
{selectedSequence.formation?.description || "Aucune description"}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
<p className="text-sm">{selectedSequence.lieu}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{getPublicCibleLabel(selectedSequence.publicCible)}
</span>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
<p className="text-sm">
{selectedSequence.nbInscrits || 0} / {selectedSequence.capaciteMax}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Statut</p>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(selectedSequence.statut)}`}>
{selectedSequence.statut}
</span>
</div>
{selectedSequence.formateur && (
<div>
<p className="text-sm font-medium text-muted-foreground">Formateur</p>
<p className="text-sm">{selectedSequence.formateur}</p>
</div>
)}
{selectedSequence.dateBloquage && (
<div>
<p className="text-sm font-medium text-muted-foreground">Date de blocage</p>
<p className="text-sm">
{format(parseISO(selectedSequence.dateBloquage), "dd/MM/yyyy", { locale: fr })}
</p>
</div>
)}
</div>
{selectedSequence.datesFormation && selectedSequence.datesFormation.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
<div className="space-y-1">
{selectedSequence.datesFormation.map((dateForm: any, index: number) => (
<div key={index} className="text-sm bg-muted p-2 rounded">
Du {format(parseISO(dateForm.dateDebut), "dd/MM/yyyy", { locale: fr })} au{" "}
{format(parseISO(dateForm.dateFin), "dd/MM/yyyy", { locale: fr })}
</div>
))}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}