**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
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { useAuth } from "@/_core/hooks/useAuth";
|
|
import DashboardLayout from "@/components/DashboardLayout";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Calendar, FileText, MapPin, Upload, Users, Award } from "lucide-react";
|
|
import { useParams } from "wouter";
|
|
import { toast } from "sonner";
|
|
import { useState } from "react";
|
|
|
|
function AttestationButton({ inscriptionId, present }: { inscriptionId: number; present: boolean }) {
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const genererAttestation = trpc.attestations.generer.useMutation();
|
|
|
|
const handleGenerer = async () => {
|
|
if (!present) {
|
|
toast.error("L'apprenant doit être marqué comme présent pour générer une attestation");
|
|
return;
|
|
}
|
|
|
|
setIsGenerating(true);
|
|
try {
|
|
const result = await genererAttestation.mutateAsync({ inscriptionId });
|
|
if (result.success && result.attestation) {
|
|
toast.success(result.message || "Attestation générée avec succès");
|
|
// Ouvrir le PDF dans un nouvel onglet
|
|
window.open(result.attestation.pdfUrl, "_blank");
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Erreur lors de la génération de l'attestation");
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleGenerer}
|
|
disabled={!present || isGenerating}
|
|
>
|
|
<Award className="h-4 w-4 mr-1" />
|
|
{isGenerating ? "Génération..." : "Générer"}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export default function FormateurSequence() {
|
|
const { id } = useParams();
|
|
const sequenceId = parseInt(id || "0");
|
|
const formateurId = 1; // TODO: Récupérer depuis la base de données
|
|
|
|
const { data: sequence, isLoading: loadingSequence } = trpc.formateur.detailSequence.useQuery({
|
|
sequenceId,
|
|
formateurId,
|
|
});
|
|
|
|
const { data: apprenants, isLoading: loadingApprenants } = trpc.formateur.apprenants.useQuery({
|
|
sequenceId,
|
|
});
|
|
|
|
const { data: supports, isLoading: loadingSupports, refetch: refetchSupports } = trpc.formateur.supports.useQuery({
|
|
sequenceId,
|
|
});
|
|
|
|
const validerPresenceMutation = trpc.formateur.validerPresence.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Présence mise à jour");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const handlePresenceChange = (inscriptionId: number, present: boolean) => {
|
|
validerPresenceMutation.mutate({
|
|
inscriptionId,
|
|
present,
|
|
});
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return new Date(date).toLocaleDateString("fr-FR", {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const formatTime = (date: Date) => {
|
|
return new Date(date).toLocaleTimeString("fr-FR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return bytes + " B";
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
};
|
|
|
|
if (loadingSequence) {
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
if (!sequence) {
|
|
return (
|
|
<DashboardLayout>
|
|
<Card>
|
|
<CardContent className="py-12">
|
|
<div className="text-center text-muted-foreground">
|
|
<p>Séquence non trouvée ou accès non autorisé</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="space-y-6">
|
|
{/* En-tête */}
|
|
<div>
|
|
<h1 className="page-title">{sequence.nom}</h1>
|
|
<p className="text-muted-foreground">{sequence.formationNom}</p>
|
|
</div>
|
|
|
|
{/* Informations générales */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informations</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{sequence.lieu && (
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
|
<span>{sequence.lieu}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
<span>Capacité maximale: {sequence.capaciteMax} apprenants</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Dates de formation */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Dates de formation
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{sequence.dates?.map((date, index) => (
|
|
<div key={date.id} className="flex items-center gap-2 text-sm">
|
|
<span className="font-medium">Jour {index + 1}:</span>
|
|
<span>{formatDate(date.dateDebut)}</span>
|
|
<span className="text-muted-foreground">
|
|
({formatTime(date.dateDebut)} - {formatTime(date.dateFin)})
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Onglets */}
|
|
<Tabs defaultValue="apprenants" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="apprenants">
|
|
<Users className="h-4 w-4 mr-2" />
|
|
Apprenants
|
|
</TabsTrigger>
|
|
<TabsTrigger value="supports">
|
|
<FileText className="h-4 w-4 mr-2" />
|
|
Supports
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="apprenants" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Liste des apprenants inscrits</CardTitle>
|
|
<CardDescription>
|
|
{apprenants?.length || 0} apprenant(s) inscrit(s)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loadingApprenants ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : !apprenants || apprenants.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Aucun apprenant inscrit
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Nom</TableHead>
|
|
<TableHead>Prénom</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Fonction</TableHead>
|
|
<TableHead>Établissement</TableHead>
|
|
<TableHead>Statut</TableHead>
|
|
<TableHead className="text-center">Présent</TableHead>
|
|
<TableHead className="text-center">Attestation</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{apprenants.map((apprenant) => (
|
|
<TableRow key={apprenant.inscriptionId}>
|
|
<TableCell className="font-medium">{apprenant.nom}</TableCell>
|
|
<TableCell>{apprenant.prenom}</TableCell>
|
|
<TableCell>{apprenant.email}</TableCell>
|
|
<TableCell className="capitalize">{apprenant.fonction.replace("_", " ")}</TableCell>
|
|
<TableCell>{apprenant.codeEtablissement}</TableCell>
|
|
<TableCell>
|
|
<span
|
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
|
apprenant.statut === "confirmee"
|
|
? "bg-green-100 text-green-800"
|
|
: apprenant.statut === "liste_attente"
|
|
? "bg-yellow-100 text-yellow-800"
|
|
: "bg-red-100 text-red-800"
|
|
}`}
|
|
>
|
|
{apprenant.statut === "confirmee"
|
|
? "Confirmée"
|
|
: apprenant.statut === "liste_attente"
|
|
? "Liste d'attente"
|
|
: "Annulée"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Checkbox
|
|
checked={apprenant.statut === "confirmee"}
|
|
onCheckedChange={(checked) =>
|
|
handlePresenceChange(apprenant.inscriptionId, checked as boolean)
|
|
}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<AttestationButton inscriptionId={apprenant.inscriptionId} present={apprenant.statut === "confirmee"} />
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="supports" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Supports de formation</CardTitle>
|
|
<CardDescription>
|
|
Documents et ressources pour cette séquence
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<Button className="w-full" onClick={() => toast.info("Fonctionnalité d'upload à venir")}>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
Ajouter un support
|
|
</Button>
|
|
|
|
{loadingSupports ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : !supports || supports.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Aucun support ajouté
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{supports.map((support) => (
|
|
<div
|
|
key={support.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">{support.nomFichier}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{support.typeFichier} • {formatFileSize(support.tailleFichier)}
|
|
{support.description && ` • ${support.description}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => window.open(support.urlFichier, "_blank")}
|
|
>
|
|
Télécharger
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|