**Corrections d'erreurs :** 1. **Erreurs SQL DATE_FORMAT corrigées** (analyticsDb.ts) : - Remplacement des sous-requêtes corrélées par des JOIN dans getTauxRemplissageByMonth() - Utilisation de COUNT(CASE WHEN...) au lieu de SUM(SELECT COUNT(*)) - Ajout de leftJoin pour éviter les erreurs de sous-requêtes 2. **Erreur React "Cannot read properties of null" corrigée** (AdminRapportPublicCible.tsx) : - Ajout de l'opérateur null-safe `?.` pour accéder à `insc.apprenant?.fonction` - Vérification explicite si la fonction est null/undefined - Gestion du cas "public cible = tous" qui correspond toujours 3. **Tests réussis** : - Page rapport-public-cible : affiche correctement les statistiques et recommandations - Tableau de bord analytique : graphiques et statistiques fonctionnels - Page suivi questionnaires : affiche correctement les données **Harmonisation charte graphique :** - Application du style bleu clair (#6B9FE8) avec ombre portée à tous les titres des 32 pages - Création de la classe CSS `.page-title` réutilisable dans index.css - Cohérence visuelle sur toute l'application **Résultat :** ✅ Toutes les erreurs SQL et React sont corrigées ✅ Les pages fonctionnent correctement sans erreur ✅ Charte graphique harmonisée sur toutes les pages
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { useRoute } from "wouter";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useState, useMemo } from "react";
|
|
import { toast } from "sonner";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { APP_LOGO, APP_TITLE } from "@/const";
|
|
|
|
export default function Inscription() {
|
|
const [, params] = useRoute("/inscription/:lien");
|
|
const lien = params?.lien || "";
|
|
|
|
const [formData, setFormData] = useState({
|
|
nom: "",
|
|
prenom: "",
|
|
email: "",
|
|
codeEtablissement: "",
|
|
fonction: "autre" as "directeur" | "chef_service" | "autre",
|
|
});
|
|
const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null);
|
|
const [inscriptionSuccess, setInscriptionSuccess] = useState(false);
|
|
const [inscriptionStatut, setInscriptionStatut] = useState<string>("");
|
|
|
|
const { data: formation, isLoading: loadingFormation } = trpc.formations.getByLien.useQuery({ lien });
|
|
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.listByFormation.useQuery(
|
|
{ formationId: formation?.id || 0 },
|
|
{ enabled: !!formation?.id }
|
|
);
|
|
|
|
const utils = trpc.useUtils();
|
|
const inscriptionMutation = trpc.inscriptions.inscrire.useMutation({
|
|
onSuccess: (data) => {
|
|
setInscriptionSuccess(true);
|
|
setInscriptionStatut(data.statut);
|
|
utils.sequences.listByFormation.invalidate({ formationId: formation?.id || 0 });
|
|
utils.apprenants.list.invalidate(); // Invalider la liste des apprenants
|
|
if (data.statut === "confirmee") {
|
|
toast.success("Inscription confirmée avec succès !");
|
|
} else {
|
|
toast.info("Vous êtes inscrit en liste d'attente.");
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message);
|
|
},
|
|
});
|
|
|
|
const createApprenantMutation = trpc.apprenants.createPublic.useMutation();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!selectedSequenceId) {
|
|
toast.error("Veuillez sélectionner une sequence");
|
|
return;
|
|
}
|
|
|
|
// Vérifier si le public cible correspond à la fonction de l'apprenant
|
|
const selectedSequence = sequences?.find(s => s.id === selectedSequenceId);
|
|
if (selectedSequence && selectedSequence.publicCible) {
|
|
const publicCibleMap: Record<string, string> = {
|
|
"directeur": "directeur",
|
|
"chef_service": "chef_service",
|
|
"autre": "autre"
|
|
};
|
|
|
|
if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) {
|
|
const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" :
|
|
selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Autre";
|
|
const fonctionLabel = formData.fonction === "directeur" ? "Directeur" :
|
|
formData.fonction === "chef_service" ? "Chef de service" : "Autre";
|
|
|
|
toast.info(
|
|
`Information : Cette séquence est destinée aux ${publicCibleLabel}, mais vous êtes inscrit(e) en tant que ${fonctionLabel}. Vous pouvez tout de même vous inscrire.`,
|
|
{ duration: 8000 }
|
|
);
|
|
|
|
// Attendre 2 secondes pour que l'utilisateur puisse lire l'alerte
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Vérifier si l'apprenant existe déjà
|
|
const existingApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
|
|
|
let apprenantId: number;
|
|
|
|
if (existingApprenant) {
|
|
apprenantId = existingApprenant.id;
|
|
} else {
|
|
// Créer l'apprenant (utiliser la procédure publique)
|
|
await createApprenantMutation.mutateAsync(formData);
|
|
// Récupérer l'apprenant nouvellement créé
|
|
const newApprenant = await utils.client.apprenants.getByEmail.query({ email: formData.email });
|
|
if (!newApprenant) {
|
|
throw new Error("Erreur lors de la création de l'apprenant");
|
|
}
|
|
apprenantId = newApprenant.id;
|
|
}
|
|
|
|
// Inscrire l'apprenant à la sequence
|
|
await inscriptionMutation.mutateAsync({
|
|
apprenantId,
|
|
sequenceId: selectedSequenceId,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Erreur lors de l'inscription:", error);
|
|
}
|
|
};
|
|
|
|
// Calculer les places disponibles pour chaque sequence
|
|
const sessionsAvecPlaces = useMemo(() => {
|
|
if (!sequences) return [];
|
|
|
|
return sequences.map(sequence => {
|
|
const now = new Date();
|
|
const dateBlocage = new Date(sequence.dateBlocage);
|
|
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
|
|
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits || 0));
|
|
|
|
return {
|
|
...sequence,
|
|
isBloquee,
|
|
placesRestantes,
|
|
};
|
|
});
|
|
}, [sequences]);
|
|
|
|
const formatDate = (dateValue: Date | string) => {
|
|
if (!dateValue) return "N/A";
|
|
|
|
try {
|
|
let localDate: Date;
|
|
|
|
// Si c'est déjà un objet Date JavaScript, l'utiliser directement
|
|
if (dateValue instanceof Date) {
|
|
localDate = dateValue;
|
|
} else {
|
|
// Parser la date MySQL sans conversion UTC
|
|
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 "N/A";
|
|
}
|
|
|
|
const [, year, month, day, hours, minutes] = match;
|
|
|
|
// Créer une Date en heure locale
|
|
localDate = new Date(
|
|
parseInt(year),
|
|
parseInt(month) - 1,
|
|
parseInt(day),
|
|
parseInt(hours),
|
|
parseInt(minutes)
|
|
);
|
|
}
|
|
|
|
return format(localDate, "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
|
|
} catch (error) {
|
|
console.error('Erreur de formatage de date:', error, dateValue);
|
|
return "N/A";
|
|
}
|
|
};
|
|
|
|
if (loadingFormation || loadingSessions) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-2xl">
|
|
<CardHeader>
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-96 mt-2" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!formation || !formation.actif) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-2xl">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<XCircle className="w-6 h-6" />
|
|
<CardTitle>Formation introuvable</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">
|
|
Cette formation n'existe pas ou n'est plus disponible.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (inscriptionSuccess) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-2xl">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2 text-green-600">
|
|
<CheckCircle className="w-8 h-8" />
|
|
<CardTitle className="text-2xl">Inscription réussie !</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-lg">
|
|
{inscriptionStatut === "confirmee"
|
|
? "Votre inscription à la formation a été confirmée."
|
|
: "Vous avez été ajouté à la liste d'attente. Nous vous contacterons si une place se libère."}
|
|
</p>
|
|
<div className="bg-blue-50 p-4 rounded-lg space-y-2">
|
|
<p className="font-semibold">Prochaines étapes :</p>
|
|
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
|
<li>Vous recevrez un email de confirmation avec tous les détails</li>
|
|
<li>Une invitation Outlook sera envoyée pour bloquer votre agenda</li>
|
|
</ul>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Pour toute question, veuillez contacter le service RH.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="text-center space-y-4">
|
|
<div className="flex items-center justify-center gap-3">
|
|
<img src={APP_LOGO} alt={APP_TITLE} className="h-12 w-12" />
|
|
<h1 className="text-4xl font-bold text-gray-900">{formation.nom}</h1>
|
|
</div>
|
|
{formation.description && (
|
|
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
|
{formation.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sessions disponibles */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sessions disponibles</CardTitle>
|
|
<CardDescription>
|
|
Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{sessionsAvecPlaces.map((sequence) => (
|
|
<div
|
|
key={sequence.id}
|
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
|
selectedSequenceId === sequence.id
|
|
? "border-blue-500 bg-blue-50"
|
|
: sequence.isBloquee
|
|
? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
|
|
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
|
|
}`}
|
|
onClick={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2 flex-1">
|
|
<h3 className="font-semibold text-lg">{sequence.nom}</h3>
|
|
<div className="space-y-1 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4" />
|
|
<div className="space-y-0.5">
|
|
{sequence.dates.map((date: any) => (
|
|
<div key={date.id}>
|
|
<span className="font-medium">Date {date.ordre}:</span> {formatDate(date.dateDebut)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="w-4 h-4" />
|
|
<span>{sequence.lieu}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4" />
|
|
<span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4" />
|
|
<span>{sequence.placesRestantes} places disponibles</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{sequence.isBloquee ? (
|
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
Fermée
|
|
</span>
|
|
) : sequence.placesRestantes === 0 ? (
|
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
|
<AlertCircle className="w-3 h-3 mr-1" />
|
|
Complète
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Disponible
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Aucune sequence disponible pour le moment.
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Formulaire d'inscription */}
|
|
{selectedSequenceId && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Vos informations</CardTitle>
|
|
<CardDescription>
|
|
Complétez le formulaire pour finaliser votre inscription
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="nom">Nom *</Label>
|
|
<Input
|
|
id="nom"
|
|
value={formData.nom}
|
|
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
|
|
placeholder="Dupont"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="prenom">Prénom *</Label>
|
|
<Input
|
|
id="prenom"
|
|
value={formData.prenom}
|
|
onChange={(e) => setFormData({ ...formData, prenom: e.target.value })}
|
|
placeholder="Jean"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email professionnel *</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
placeholder="jean.dupont@example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="codeEtablissement">Code établissement *</Label>
|
|
<Input
|
|
id="codeEtablissement"
|
|
value={formData.codeEtablissement}
|
|
onChange={(e) => setFormData({ ...formData, codeEtablissement: e.target.value })}
|
|
placeholder="ETB001"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fonction">Fonction *</Label>
|
|
<Select value={formData.fonction} onValueChange={(value: any) => setFormData({ ...formData, fonction: value })}>
|
|
<SelectTrigger id="fonction">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="directeur">Directeur</SelectItem>
|
|
<SelectItem value="chef_service">Chef de service</SelectItem>
|
|
<SelectItem value="autre">Autre</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
<p className="text-sm text-amber-800">
|
|
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la sequence.
|
|
Après cette date, aucune modification ne sera possible.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
|
|
>
|
|
{inscriptionMutation.isPending || createApprenantMutation.isPending
|
|
? "Inscription en cours..."
|
|
: "Confirmer mon inscription"}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|