Checkpoint: Tableau de bord personnalisé pour formateurs avec statistiques (total séquences, séquences à venir, total apprenants) et liste des prochaines interventions avec détails (dates, lieu, capacité). Redirection automatique des formateurs vers /formateur au lieu de /admin. Notifications automatiques par email pour les formateurs lors de nouvelles inscriptions, annulations, capacité maximale atteinte et places disponibles. Menus Configuration, Traçabilité et Analyses masqués pour les formateurs.

This commit is contained in:
Manus
2026-01-09 11:05:07 -05:00
parent 5f1df3f9cc
commit 55f0f2795e
8 changed files with 560 additions and 6 deletions

View File

@@ -33,9 +33,7 @@ import AdminAttestations from "./pages/admin/AdminAttestations";
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
import Inscription from "./pages/Inscription";
import Login from "./pages/Login";
import FormateurDashboard from "./pages/formateur/FormateurDashboard";
import FormateurSequence from "./pages/formateur/FormateurSequence";
import FormateurHistorique from "./pages/formateur/FormateurHistorique";
import FormateurDashboard from "./pages/FormateurDashboard";
function Router() {
return (
@@ -70,8 +68,6 @@ function Router() {
<Route path={"/admin/questionnaires/:id"} component={AdminQuestionnaireEdit} />
<Route path={"/questionnaire/:token"} component={QuestionnaireReponse} />
<Route path={"/formateur"} component={FormateurDashboard} />
<Route path={"/formateur/sequence/:id"} component={FormateurSequence} />
<Route path={"/formateur/historique"} component={FormateurHistorique} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -102,11 +102,19 @@ export default function DashboardLayout({
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
const [location, setLocation] = useLocation();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
// Rediriger les formateurs vers leur tableau de bord
useEffect(() => {
if (user && user.role === 'formateur' && location === '/admin') {
setLocation('/formateur');
}
}, [user, location, setLocation]);
if (loading) {
return <DashboardLayoutSkeleton />
}
@@ -182,6 +190,17 @@ function DashboardLayoutContent({
return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses';
}
return true;
}).map(section => {
// Modifier le chemin du tableau de bord pour les formateurs
if (section.title === 'Tableau de bord' && user?.role === 'formateur') {
return {
...section,
items: section.items.map(item =>
item.path === '/admin' ? { ...item, path: '/formateur' } : item
)
};
}
return section;
});
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);

View File

@@ -0,0 +1,203 @@
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Calendar, Users, GraduationCap, Clock, MapPin, AlertCircle } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Skeleton } from "@/components/ui/skeleton";
export default function FormateurDashboard() {
const { data: stats, isLoading: statsLoading } = trpc.formateurs.dashboardStats.useQuery();
const { data: prochainesSequences, isLoading: sequencesLoading } = trpc.formateurs.prochainesSequences.useQuery({ limit: 10 });
if (statsLoading) {
return (
<div className="container py-8 space-y-8">
<div>
<Skeleton className="h-10 w-96 mb-2" />
<Skeleton className="h-6 w-64" />
</div>
<div className="grid gap-6 md:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="container py-8 space-y-8">
{/* En-tête */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Tableau de bord formateur</h1>
<p className="text-muted-foreground mt-2">
Vue d'ensemble de vos formations et prochaines interventions
</p>
</div>
{/* Cartes de statistiques */}
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total séquences</CardTitle>
<GraduationCap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.totalSequences || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Toutes vos séquences de formation
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Séquences à venir</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.sequencesAvenir || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Avec au moins une date future
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total apprenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.totalInscrits || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Inscrits confirmés à vos séquences
</p>
</CardContent>
</Card>
</div>
{/* Liste des prochaines séquences */}
<Card>
<CardHeader>
<CardTitle>Prochaines séquences</CardTitle>
<CardDescription>
Vos interventions à venir triées par date
</CardDescription>
</CardHeader>
<CardContent>
{sequencesLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
) : prochainesSequences && prochainesSequences.length > 0 ? (
<div className="space-y-4">
{prochainesSequences.map((seq: any) => {
const pourcentageRemplissage = seq.capaciteMax > 0
? Math.round((seq.nbInscrits / seq.capaciteMax) * 100)
: 0;
const estComplete = pourcentageRemplissage >= 100;
const estPresqueComplete = pourcentageRemplissage >= 80 && pourcentageRemplissage < 100;
return (
<Card key={seq.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-lg">{seq.nom}</CardTitle>
<CardDescription className="flex items-center gap-2">
<GraduationCap className="h-4 w-4" />
{seq.formation?.nom || "Formation inconnue"}
</CardDescription>
</div>
<Badge variant={seq.statut === 'ouverte' ? 'default' : seq.statut === 'bloquee' ? 'secondary' : 'outline'}>
{seq.statut === 'ouverte' ? 'Ouverte' : seq.statut === 'bloquee' ? 'Bloquée' : 'Terminée'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Lieu */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>{seq.lieu}</span>
</div>
{/* Dates */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Clock className="h-4 w-4" />
<span>Dates de formation</span>
</div>
<div className="flex flex-wrap gap-2 ml-6">
{seq.dates.map((date: any) => {
const dateDebut = new Date(date.dateDebut);
const dateFin = new Date(date.dateFin);
const estFuture = dateDebut >= new Date();
return (
<Badge
key={date.id}
variant={estFuture ? "default" : "outline"}
className="text-xs"
>
{format(dateDebut, "dd MMM", { locale: fr })}
{dateDebut.toDateString() !== dateFin.toDateString() &&
` - ${format(dateFin, "dd MMM", { locale: fr })}`
}
</Badge>
);
})}
</div>
</div>
{/* Capacité */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
<span className="font-medium">Inscriptions</span>
</div>
<span className="text-muted-foreground">
{seq.nbInscrits} / {seq.capaciteMax}
</span>
</div>
<div className="space-y-1">
<Progress
value={pourcentageRemplissage}
className="h-2"
/>
{estComplete && (
<div className="flex items-center gap-1 text-xs text-orange-600">
<AlertCircle className="h-3 w-3" />
<span>Capacité maximale atteinte</span>
</div>
)}
{estPresqueComplete && (
<div className="flex items-center gap-1 text-xs text-yellow-600">
<AlertCircle className="h-3 w-3" />
<span>Presque complète ({pourcentageRemplissage}%)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Aucune séquence à venir pour le moment</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}