✅ Base de données : colonne "formateur" ajoutée à la table sequences (VARCHAR 255, nullable) ✅ Backend : procédures tRPC mises à jour - sequences.create : champ formateur ajouté (optionnel) - sequences.update : champ formateur ajouté (optionnel) ✅ Frontend : formulaires mis à jour - Formulaire de création : champ "Formateur" ajouté avec placeholder - Formulaire de modification : champ "Formateur" ajouté avec placeholder - Le champ est non obligatoire comme demandé Le champ formateur permet maintenant de spécifier le nom du formateur pour chaque séquence de formation lors de la création ou de la modification.
903 lines
36 KiB
TypeScript
903 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 { 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" | "tous" | "autre",
|
|
formateur: "",
|
|
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",
|
|
formateur: "",
|
|
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",
|
|
formateur: sequence.formateur || "",
|
|
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,
|
|
formateur: formData.formateur || undefined,
|
|
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>
|
|
<Input
|
|
id="formateur"
|
|
value={formData.formateur}
|
|
onChange={(e) => setFormData({ ...formData, formateur: e.target.value })}
|
|
placeholder="Nom du formateur (optionnel)"
|
|
/>
|
|
</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>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>
|
|
<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>{sequence.nbInscrits || 0} / {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-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>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-formateur">Formateur</Label>
|
|
<Input
|
|
id="edit-formateur"
|
|
value={formData.formateur}
|
|
onChange={(e) => setFormData({ ...formData, formateur: e.target.value })}
|
|
placeholder="Nom du formateur (optionnel)"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|