- Correction du placeholder du formateur dans le formulaire de modification de séquence (suppression de "(optionnel)") - Ajout d'une colonne "Formateur" dans le tableau de la liste des séquences - Le formateur s'affiche maintenant dans la liste avec "-" si non assigné - Tests réussis : le placeholder est correct et la colonne formateur est visible
905 lines
36 KiB
TypeScript
905 lines
36 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 { trpc } from "@/lib/trpc";
|
|
import { Calendar, Edit, Eye, Plus, Trash2, X } 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";
|
|
|
|
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 [filterPublicCible, setFilterPublicCible] = useState<string>("all");
|
|
const [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date");
|
|
|
|
const [formData, setFormData] = useState({
|
|
formationId: "",
|
|
nom: "",
|
|
lieu: "",
|
|
publicCible: "autre" as "directeur" | "chef_service" | "autre",
|
|
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: 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 resetForm = () => {
|
|
setFormData({
|
|
formationId: "",
|
|
nom: "",
|
|
lieu: "",
|
|
publicCible: "autre",
|
|
capaciteMax: "12",
|
|
dateBlocage: "",
|
|
statut: "ouverte",
|
|
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) {
|
|
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",
|
|
dates: (sequence.dates || []).map((d: any) => ({
|
|
dateDebut: safeFormatDate(d.dateDebut),
|
|
dateFin: safeFormatDate(d.dateFin),
|
|
ordre: d.ordre || 1,
|
|
})),
|
|
});
|
|
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,
|
|
publicCible: formData.publicCible,
|
|
capaciteMax: parseInt(formData.capaciteMax),
|
|
dateBlocage: formData.dateBlocage,
|
|
statut: formData.statut,
|
|
dates: formData.dates,
|
|
};
|
|
|
|
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;
|
|
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";
|
|
};
|
|
|
|
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="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="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
|
|
/>
|
|
</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>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>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) => (
|
|
<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 className="text-sm">{sequence.formateur || "-"}</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" />
|
|
</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-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
|
|
/>
|
|
</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>
|
|
);
|
|
}
|