Checkpoint: Espace Formateur - Fonctionnalités complètes

 **Nouvelles fonctionnalités** :

**Base de données** :
- Nouvelle table `supportsFormation` pour stocker les documents uploadés par les formateurs
- Champs : sequenceId, formateurId, nomFichier, typeFichier, tailleFichier, urlFichier, s3Key, description
- Migration effectuée avec succès

**Backend (server/formateurDb.ts)** :
- `getCalendrierFormateur()` : Récupère les interventions à venir avec dates, lieux et nombre d'inscrits
- `getApprenantsSequence()` : Liste des apprenants inscrits à une séquence
- `getSupportsSequence()` : Liste des supports de formation d'une séquence
- `ajouterSupport()` : Upload d'un nouveau support
- `supprimerSupport()` : Suppression d'un support avec vérification de propriété
- `validerPresence()` : Validation de la présence d'un apprenant
- `getHistoriqueFormateur()` : Historique des formations passées avec statistiques
- `getDetailSequence()` : Détails complets d'une séquence avec dates

**API tRPC (server/routers.ts)** :
- `formateur.calendrier` : Calendrier des interventions avec filtres de dates
- `formateur.apprenants` : Liste des apprenants d'une séquence
- `formateur.supports` : Gestion des supports (liste, ajout, suppression)
- `formateur.validerPresence` : Validation des présences en ligne
- `formateur.historique` : Historique complet des formations
- `formateur.detailSequence` : Détails d'une séquence

**Pages frontend** :
- `/formateur` : **FormateurDashboard** - Calendrier des interventions à venir groupées par date
- `/formateur/sequence/:id` : **FormateurSequence** - Détails séquence avec onglets Apprenants et Supports
- `/formateur/historique` : **FormateurHistorique** - Historique des formations avec taux de présence

**Fonctionnalités implémentées** :
- 📅 Calendrier des interventions avec dates, heures, lieux et nombre d'inscrits
- 👥 Liste des apprenants avec informations complètes (nom, email, fonction, établissement)
-  Validation des présences en ligne avec checkboxes interactives
- 📎 Gestion des supports de formation (liste, téléchargement)
- 📊 Historique des formations avec statistiques (inscrits, présents, taux)
- 🎨 Interface moderne avec cartes, tableaux et onglets

**Notes** :
- L'ID du formateur est actuellement fixé à 1 (TODO: lier à l'utilisateur connecté)
- La fonctionnalité d'upload de supports est préparée mais nécessite l'implémentation du composant d'upload
- Les présences sont gérées via le statut de l'inscription (confirmée/annulée)
This commit is contained in:
Manus Sandbox
2025-12-07 16:39:55 -05:00
parent 239fea6797
commit eed50c7c08
10 changed files with 2626 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
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 } from "lucide-react";
import { useParams } from "wouter";
import { toast } from "sonner";
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="text-3xl font-bold">{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>
</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>
</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>
);
}