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

1223 lines
50 KiB
TypeScript

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 { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { Calendar, Edit, Eye, Plus, Trash2, X, Bell, Maximize2, Minimize2, Send } from "lucide-react";
import FormateurCombobox from "@/components/FormateurCombobox";
import CapacityProgressBar from "@/components/CapacityProgressBar";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { format } from "date-fns";
import { useAuth } from "@/_core/hooks/useAuth";
interface DateFormation {
id?: number; // Optionnel car les nouvelles dates n'ont pas encore d'ID
dateDebut: string;
dateFin: string;
ordre: number;
}
export default function AdminSequences() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [, setLocation] = useLocation();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [editingSequence, setEditingSequence] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState("");
const [filterLieu, setFilterLieu] = useState<string>("all");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [filterPublicCible, setFilterPublicCible] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date");
const [isCompactView, setIsCompactView] = useState(true);
const [isConfirmTestOpen, setIsConfirmTestOpen] = useState(false);
const [sequenceToTest, setSequenceToTest] = useState<number | null>(null);
const [testEmail, setTestEmail] = useState("");
const [sendingProgress, setSendingProgress] = useState<{ current: number; total: number } | null>(null);
const [formData, setFormData] = useState({
formationId: "",
nom: "",
lieu: "",
publicCible: "autre" as "directeur" | "chef_service" | "autre" | "tous",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
formateur: null as number | null,
dates: [
{ dateDebut: "", dateFin: "", ordre: 1 }
] as DateFormation[],
});
const utils = trpc.useUtils();
const { data: formations } = trpc.formations.list.useQuery();
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
const createMutation = trpc.sequences.create.useMutation({
onSuccess: () => {
toast.success("Séquence créée avec succès");
setIsCreateOpen(false);
resetForm();
utils.sequences.list.invalidate();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const updateMutation = trpc.sequences.update.useMutation({
onSuccess: async () => {
toast.success("Séquence modifiée avec succès");
setIsEditOpen(false);
setEditingSequence(null);
// Forcer un refetch immédiat au lieu d'une simple invalidation
await utils.sequences.list.refetch();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const deleteMutation = trpc.sequences.delete.useMutation({
onSuccess: () => {
toast.success("Séquence supprimée avec succès");
utils.sequences.list.invalidate();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const testerRappelMutation = trpc.rappels.testerRappel.useMutation({
onSuccess: (data) => {
setSendingProgress(null);
toast.success(`${data.nbEnvoyes} email(s) de test envoyé(s) avec succès`);
setIsConfirmTestOpen(false);
setSequenceToTest(null);
setTestEmail("");
},
onError: (error) => {
setSendingProgress(null);
toast.error(`Erreur lors de l'envoi du test : ${error.message}`);
setIsConfirmTestOpen(false);
setSequenceToTest(null);
setTestEmail("");
},
});
const handleTestRappel = (sequenceId: number, sequence: any) => {
if (!sequence.dates || sequence.dates.length === 0) {
toast.error("Cette séquence n'a pas de dates configurées");
return;
}
setSequenceToTest(sequenceId);
setTestEmail(""); // Réinitialiser l'email
setIsConfirmTestOpen(true);
};
const confirmTestRappel = () => {
if (sequenceToTest !== null) {
if (!testEmail || !testEmail.includes('@')) {
toast.error("Veuillez saisir une adresse email valide");
return;
}
testerRappelMutation.mutate({
sequenceId: sequenceToTest,
emailTest: testEmail
});
}
};
const resetForm = () => {
setFormData({
formationId: "",
nom: "",
lieu: "",
publicCible: "autre",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte",
formateur: null,
dates: [{ dateDebut: "", dateFin: "", ordre: 1 }],
});
};
const handleEdit = (sequence: any) => {
setEditingSequence(sequence);
// Fonction helper pour formater une date en toute sécurité
const safeFormatDate = (dateValue: any): string => {
if (!dateValue) return "";
try {
// Si c'est un objet Date JavaScript
if (dateValue instanceof Date) {
// MySQL stocke les dates en heure locale (pas UTC)
// Donc on utilise les méthodes locales pour extraire les valeurs
const year = dateValue.getFullYear();
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
const day = String(dateValue.getDate()).padStart(2, '0');
const hours = String(dateValue.getHours()).padStart(2, '0');
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Si c'est déjà une chaîne au bon format, la retourner directement
if (typeof dateValue === 'string' && dateValue.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)) {
return dateValue.substring(0, 16); // Garder seulement YYYY-MM-DDTHH:mm
}
// Parser la date MySQL (format: "YYYY-MM-DD HH:mm:ss")
const dateStr = String(dateValue);
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
if (!match) {
console.warn('Format de date non reconnu:', dateValue);
return "";
}
const [, year, month, day, hours, minutes] = match;
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch (error) {
console.error('Erreur de formatage de date:', error, dateValue);
return "";
}
};
setFormData({
formationId: sequence.formationId.toString(),
nom: sequence.nom || "",
lieu: sequence.lieu || "",
publicCible: sequence.publicCible || "autre",
capaciteMax: sequence.capaciteMax?.toString() || "12",
dateBlocage: safeFormatDate(sequence.dateBlocage),
statut: sequence.statut || "ouverte",
formateur: sequence.formateurId?.toString() || "",
dates: (sequence.dates || []).map((d: any) => ({
id: d.id, // Inclure l'ID pour que le backend puisse identifier les dates existantes
dateDebut: safeFormatDate(d.dateDebut),
dateFin: safeFormatDate(d.dateFin),
ordre: d.ordre || 1,
})),
});
setIsEditOpen(true);
};
// Convertir une date locale (datetime-local) en ISO UTC
const convertLocalToUTC = (localDateStr: string): string => {
if (!localDateStr || localDateStr.trim() === '') return '';
// datetime-local retourne "YYYY-MM-DDTHH:mm"
// On doit le traiter comme heure locale et le convertir en UTC
const localDate = new Date(localDateStr);
return localDate.toISOString();
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.dates.length) {
toast.error("Veuillez ajouter au moins une date de formation");
return;
}
// Valider que chaque date de fin est après la date de début
for (let i = 0; i < formData.dates.length; i++) {
const date = formData.dates[i];
if (date.dateDebut && date.dateFin) {
const debut = new Date(date.dateDebut);
const fin = new Date(date.dateFin);
if (fin <= debut) {
toast.error(`Date ${i + 1}: La date de fin doit être après la date de début`);
return;
}
}
}
const data = {
formationId: parseInt(formData.formationId),
nom: formData.nom,
lieu: formData.lieu,
publicCible: formData.publicCible,
capaciteMax: parseInt(formData.capaciteMax),
dateBlocage: convertLocalToUTC(formData.dateBlocage),
statut: formData.statut,
formateurId: formData.formateur ? parseInt(formData.formateur) : null,
dates: formData.dates.map(d => ({
...d,
dateDebut: convertLocalToUTC(d.dateDebut),
dateFin: convertLocalToUTC(d.dateFin),
})),
};
if (editingSequence) {
console.log('[CLIENT] Envoi de la mise à jour:', { id: editingSequence.id, ...data });
updateMutation.mutate({ id: editingSequence.id, ...data });
} else {
createMutation.mutate(data);
}
};
const handleDelete = (id: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette séquence ?")) {
deleteMutation.mutate({ id });
}
};
const addDate = () => {
if (formData.dates.length >= 4) {
toast.error("Maximum 4 dates par séquence");
return;
}
setFormData({
...formData,
dates: [...formData.dates, { dateDebut: "", dateFin: "", ordre: formData.dates.length + 1 }],
});
};
const removeDate = (index: number) => {
if (formData.dates.length <= 1) {
toast.error("Au moins une date est requise");
return;
}
const newDates = formData.dates.filter((_, i) => i !== index);
// Réordonner les dates
newDates.forEach((d, i) => d.ordre = i + 1);
setFormData({ ...formData, dates: newDates });
};
const updateDate = (index: number, field: "dateDebut" | "dateFin", value: string) => {
const newDates = [...formData.dates];
newDates[index][field] = value;
// Validation en temps réel : vérifier que la date de fin est après la date de début
if (newDates[index].dateDebut && newDates[index].dateFin) {
const debut = new Date(newDates[index].dateDebut);
const fin = new Date(newDates[index].dateFin);
if (fin <= debut) {
toast.error(`Date ${index + 1}: La date de fin doit être après la date de début`);
}
}
setFormData({ ...formData, dates: newDates });
};
// Filtrage et tri
const filteredAndSortedSequences = useMemo(() => {
if (!sequences) return [];
let filtered = sequences.filter((seq) => {
const matchSearch = seq.nom.toLowerCase().includes(searchTerm.toLowerCase());
const matchLieu = filterLieu === "all" || seq.lieu === filterLieu;
const matchStatut = filterStatut === "all" || seq.statut === filterStatut;
const matchPublicCible = filterPublicCible === "all" || seq.publicCible === filterPublicCible;
return matchSearch && matchLieu && matchStatut && matchPublicCible;
});
filtered.sort((a, b) => {
if (sortBy === "date") {
const dateA = a.dates[0] ? new Date(a.dates[0].dateDebut).getTime() : 0;
const dateB = b.dates[0] ? new Date(b.dates[0].dateDebut).getTime() : 0;
return dateA - dateB;
} else if (sortBy === "lieu") {
return a.lieu.localeCompare(b.lieu);
} else {
return b.capaciteMax - a.capaciteMax;
}
});
return filtered;
}, [sequences, searchTerm, filterLieu, filterStatut, filterPublicCible, sortBy]);
const uniqueLieux = useMemo(() => {
if (!sequences) return [];
return Array.from(new Set(sequences.map((s) => s.lieu)));
}, [sequences]);
const getFormationName = (formationId: number) => {
return formations?.find((f) => f.id === formationId)?.nom || "N/A";
};
// Générer une couleur unique par formation (en évitant vert, orange, rouge)
const formationColorMap = useMemo(() => {
if (!formations) return new Map<number, string>();
const colors = [
"text-blue-600",
"text-purple-600",
"text-pink-600",
"text-indigo-600",
"text-cyan-600",
"text-teal-600",
"text-violet-600",
"text-fuchsia-600",
"text-sky-600",
"text-amber-600",
];
const map = new Map<number, string>();
formations.forEach((formation, index) => {
map.set(formation.id, colors[index % colors.length]);
});
return map;
}, [formations]);
const getFormationColor = (formationId: number) => {
return formationColorMap.get(formationId) || "text-gray-600";
};
const formatDate = (dateValue: any) => {
if (!dateValue) return "N/A";
try {
// Si c'est déjà un objet Date JavaScript
if (dateValue instanceof Date) {
const day = String(dateValue.getDate()).padStart(2, '0');
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
const year = dateValue.getFullYear();
const hours = String(dateValue.getHours()).padStart(2, '0');
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
// Parser la date MySQL (chaîne)
const dateStr = String(dateValue);
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
if (!match) {
console.warn('Format de date non reconnu pour affichage:', dateValue);
return "N/A";
}
const [, year, month, day, hours, minutes] = match;
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
console.error('Erreur de formatage de date pour affichage:', error, dateValue);
return "N/A";
}
};
const getStatutBadge = (statut: string) => {
const colors = {
ouverte: "bg-green-100 text-green-800",
bloquee: "bg-orange-100 text-orange-800",
terminee: "bg-gray-100 text-gray-800",
};
return colors[statut as keyof typeof colors] || colors.ouverte;
};
const getPublicCibleBadge = (publicCible: string) => {
const colors = {
directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800",
tous: "bg-purple-100 text-purple-800",
autre: "bg-gray-100 text-gray-800",
};
return colors[publicCible as keyof typeof colors] || colors.autre;
};
const getPublicCibleLabel = (publicCible: string) => {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
tous: "Tous",
autre: "Autre",
};
return labels[publicCible as keyof typeof labels] || "Autre";
};
return (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="page-title">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>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsCompactView(!isCompactView)}
>
{isCompactView ? (
<>
<Maximize2 className="h-4 w-4 mr-2" />
Vue complète
</>
) : (
<>
<Minimize2 className="h-4 w-4 mr-2" />
Vue réduite
</>
)}
</Button>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
{isAdmin && (
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Séquence
</Button>
</DialogTrigger>
)}
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Créer une nouvelle séquence</DialogTitle>
<DialogDescription>
Remplissez les informations de la séquence et ajoutez jusqu'à 4 dates de formation
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="formationId">Formation *</Label>
<Select
value={formData.formationId}
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
required
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une formation" />
</SelectTrigger>
<SelectContent>
{formations?.map((formation) => (
<SelectItem key={formation.id} value={formation.id.toString()}>
{formation.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="nom">Nom de la séquence *</Label>
<Input
id="nom"
value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
placeholder="Ex: Séquence 1 - Février 2026"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lieu">Lieu *</Label>
<Input
id="lieu"
value={formData.lieu}
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
placeholder="Ex: Salle de formation A"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicCible">Public cible *</Label>
<Select
value={formData.publicCible}
onValueChange={(value: "directeur" | "chef_service" | "tous" | "autre") => setFormData({ ...formData, publicCible: value })}
>
<SelectTrigger id="publicCible">
<SelectValue placeholder="Sélectionner le public cible" />
</SelectTrigger>
<SelectContent>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="formateur">Formateur</Label>
<FormateurCombobox
value={formData.formateur || ""}
onChange={(value) => setFormData({ ...formData, formateur: value })}
/>
</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
className={(
date.dateDebut && date.dateFin &&
new Date(date.dateFin) <= new Date(date.dateDebut)
) ? "border-red-500 focus:border-red-500" : ""}
/>
{date.dateDebut && date.dateFin && new Date(date.dateFin) <= new Date(date.dateDebut) && (
<p className="text-xs text-red-500 mt-1">
La date de fin doit être après la date de début
</p>
)}
</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>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filtres et recherche</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Recherche</Label>
<Input
placeholder="Nom de la séquence..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Lieu</Label>
<Select value={filterLieu} onValueChange={setFilterLieu}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les lieux</SelectItem>
{uniqueLieux.map((lieu) => (
<SelectItem key={lieu} value={lieu}>
{lieu}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Statut</Label>
<Select value={filterStatut} onValueChange={setFilterStatut}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Public cible</Label>
<Select value={filterPublicCible} onValueChange={setFilterPublicCible}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les publics</SelectItem>
<SelectItem value="directeur">Directeurs</SelectItem>
<SelectItem value="chef_service">Chefs de service</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Trier par</Label>
<Select value={sortBy} onValueChange={(v: any) => setSortBy(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="lieu">Lieu</SelectItem>
<SelectItem value="capacite">Capacité</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4">
{filteredAndSortedSequences.length} séquence(s) trouvée(s)
</p>
</CardContent>
</Card>
{/* Liste des séquences */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Liste des séquences
</CardTitle>
<CardDescription>
{sequences?.length || 0} séquence(s) au total
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Chargement...</div>
) : filteredAndSortedSequences.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucune séquence trouvée
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Formation</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Rappels pré-formation</TableHead>
<TableHead>Rappels post-formation</TableHead>
{!isCompactView && (
<>
<TableHead>Lieu</TableHead>
<TableHead>Formateur</TableHead>
<TableHead>Public cible</TableHead>
</>
)}
<TableHead>Capacité</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedSequences.map((sequence, index) => (
<TableRow key={sequence.id} className={index % 2 === 1 ? "bg-blue-50/70" : ""}>
<TableCell className={`font-semibold ${getFormationColor(sequence.formationId)}`}>{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>
<div className="space-y-1.5">
{sequence.dates.map((date: any) => {
const rappelsPre = date.rappels?.filter((r: any) => r.timing === 'pre_formation') || [];
return (
<div key={date.id}>
{rappelsPre.length > 0 ? (
<div className="flex flex-wrap gap-1">
{rappelsPre.map((rappel: any) => (
<Badge
key={rappel.id}
variant="outline"
className="bg-blue-50 text-blue-700 border-blue-300 text-xs"
>
<Bell className="w-3 h-3 mr-1" />
{rappel.nom}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground italic">-</span>
)}
</div>
);
})}
</div>
</TableCell>
<TableCell>
<div className="space-y-1.5">
{sequence.dates.map((date: any) => {
const rappelsPost = date.rappels?.filter((r: any) => r.timing === 'post_formation') || [];
return (
<div key={date.id}>
{rappelsPost.length > 0 ? (
<div className="flex flex-wrap gap-1">
{rappelsPost.map((rappel: any) => (
<Badge
key={rappel.id}
variant="outline"
className="bg-green-50 text-green-700 border-green-300 text-xs"
>
<Bell className="w-3 h-3 mr-1" />
{rappel.nom}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground italic">-</span>
)}
</div>
);
})}
</div>
</TableCell>
{!isCompactView && (
<>
<TableCell className="max-w-xs truncate">{sequence.lieu}</TableCell>
<TableCell className="text-sm">{sequence.formateur?.nom || "-"}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
{getPublicCibleLabel(sequence.publicCible)}
</span>
</TableCell>
</>
)}
<TableCell>
<CapacityProgressBar
nbInscrits={sequence.nbInscrits || 0}
capaciteMax={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 text-blue-600" />
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(sequence)}
>
<Edit className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleTestRappel(sequence.id, sequence)}
disabled={testerRappelMutation.isPending || (sequence.nbRappelsActifs === 0)}
title={
sequence.nbRappelsActifs === 0
? "Aucun rappel actif configuré pour cette séquence"
: `Tester l'envoi de ${sequence.nbRappelsActifs} rappel(s) actif(s)`
}
className="relative"
>
<Send className={`h-4 w-4 ${
sequence.nbRappelsActifs === 0 ? 'text-gray-300' : 'text-green-600'
}`} />
{sequence.nbRappelsActifs > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-green-600 text-[10px] font-bold text-white">
{sequence.nbRappelsActifs}
</span>
)}
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(sequence.id)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</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-publicCible">Public cible *</Label>
<Select
value={formData.publicCible}
onValueChange={(value: "directeur" | "chef_service" | "tous" | "autre") => setFormData({ ...formData, publicCible: value })}
>
<SelectTrigger id="edit-publicCible">
<SelectValue placeholder="Sélectionner le public cible" />
</SelectTrigger>
<SelectContent>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-formateur">Formateur</Label>
<FormateurCombobox
value={formData.formateur}
onChange={(value) => setFormData({ ...formData, formateur: value })}
placeholder="Sélectionner un formateur"
/>
</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
className={(
date.dateDebut && date.dateFin &&
new Date(date.dateFin) <= new Date(date.dateDebut)
) ? "border-red-500 focus:border-red-500" : ""}
/>
{date.dateDebut && date.dateFin && new Date(date.dateFin) <= new Date(date.dateDebut) && (
<p className="text-xs text-red-500 mt-1">
La date de fin doit être après la date de début
</p>
)}
</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>
{/* Boîte de dialogue de confirmation pour le test de rappel */}
<Dialog open={isConfirmTestOpen} onOpenChange={setIsConfirmTestOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tester l'envoi de rappels</DialogTitle>
<DialogDescription>
Tous les rappels configurés pour cette séquence seront envoyés à l'adresse email que vous indiquez ci-dessous.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="test-email">Adresse email du destinataire</Label>
<Input
id="test-email"
type="email"
placeholder="exemple@domaine.com"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
disabled={testerRappelMutation.isPending}
/>
</div>
{/* Indicateur de progression */}
{testerRappelMutation.isPending && (
<div className="space-y-3 p-4 bg-muted/50 rounded-lg border">
<div className="flex items-center gap-3">
<div className="relative flex h-10 w-10 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-10 w-10 bg-primary items-center justify-center">
<svg className="animate-spin h-5 w-5 text-primary-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
</div>
<div className="flex-1">
<p className="text-sm font-medium">Envoi en cours...</p>
<p className="text-xs text-muted-foreground">Veuillez patienter pendant l'envoi des emails de test</p>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setIsConfirmTestOpen(false);
setSequenceToTest(null);
setTestEmail("");
}}
disabled={testerRappelMutation.isPending}
>
Annuler
</Button>
<Button
onClick={confirmTestRappel}
disabled={testerRappelMutation.isPending}
>
{testerRappelMutation.isPending ? "Envoi en cours..." : "Valider"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}