TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Pédagogie : statistiques agrégées et anonymisées par cours — maîtrise par concept,3// questions les plus échouées, erreurs récurrentes, examens blancs, signalements,4// rétroactions 👍/👎 par jour.5import { useMemo, useState } from "react";6import { BookOpen, Flag, ThumbsDown, ThumbsUp } from "lucide-react";7import { PageHeader } from "@/components/app-shell";8import { Badge, Card, EmptyState, Skeleton, Tabs, cn } from "@/components/ui";9import { ErrorBanner, HBarRow, SectionTitle, fmtDate, fmtInt, useFetchJson } from "./shared";1011type Pedagogy = {12 course: string;13 minStudents: number;14 conceptStats: { id: number; name: string; week: number | null; students: number; avg_score: number | null; masked: boolean }[];15 hardestQuestions: { id: number; question: string; difficulty: number | string | null; concept: string | null; attempts: number; success_pct: number | null }[];16 commonErrors: { concept: string | null; n: number }[];17 examStats: { title: string; attempts: number; avg_pct: number | null }[];18 flagged: { id: number; reason: string | null; created_at: string; resolved: number; message_preview: string }[];19 feedback: { day: string; up: number; down: number }[];20};2122const COURSES = [23 { key: "IMM1003", label: "IMM1003" },24 { key: "IMM1033", label: "IMM1033" },25];2627function successTone(pct: number): string {28 if (pct < 50) return "bg-red-500";29 if (pct < 75) return "bg-amber-500";30 return "bg-emerald-500";31}3233export function AdminPedagogy() {34 const [course, setCourse] = useState("IMM1003");35 const { data, error, loading, reload } = useFetchJson<Pedagogy>(`/api/admin/pedagogy?course=${course}`);3637 const maxErrors = useMemo(() => Math.max(...(data?.commonErrors ?? []).map((e) => e.n), 1), [data]);3839 return (40 <div className="animate-fade-up">41 <PageHeader42 title="Pédagogie"43 subtitle={`Statistiques agrégées et anonymisées — seuil de confidentialité : ${data?.minStudents ?? 3} étudiant·e·s`}44 actions={<Tabs tabs={COURSES} active={course} onChange={setCourse} />}45 />4647 {loading ? (48 <div className="grid gap-4 lg:grid-cols-2">49 {Array.from({ length: 4 }).map((_, i) => (50 <Skeleton key={i} className="h-64" />51 ))}52 </div>53 ) : error || !data ? (54 <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />55 ) : (56 <div className="grid gap-4 lg:grid-cols-2">57 {/* Maîtrise par concept */}58 <Card className="p-5">59 <SectionTitle sub="Score moyen de maîtrise (0 à 100 %) — masqué sous le seuil de confidentialité">60 Maîtrise moyenne par concept61 </SectionTitle>62 {data.conceptStats.length === 0 ? (63 <EmptyState icon={<BookOpen />} title="Aucun concept" description="Les concepts du cours n'ont pas encore été définis." />64 ) : (65 <div className="max-h-96 overflow-y-auto pr-1">66 {data.conceptStats.map((c) => (67 <HBarRow68 key={c.id}69 label={70 <>71 {c.week != null && <span className="mr-1.5 text-[11px] text-muted">S{c.week}</span>}72 {c.name}73 </>74 }75 value={c.masked ? 1 : c.avg_score ?? 0}76 muted={c.masked || c.avg_score == null}77 display={78 c.masked79 ? "— moins de 3 étudiants"80 : c.avg_score == null81 ? "aucune donnée"82 : `${Math.round(c.avg_score * 100)} %`83 }84 />85 ))}86 </div>87 )}88 </Card>8990 {/* Questions les plus échouées */}91 <Card className="p-5">92 <SectionTitle sub="Taux de réussite le plus faible en premier (minimum 3 tentatives)">93 Questions de quiz les plus échouées94 </SectionTitle>95 {data.hardestQuestions.length === 0 ? (96 <EmptyState icon={<BookOpen />} title="Pas encore assez de tentatives" description="Les statistiques apparaîtront quand au moins 3 étudiant·e·s auront répondu aux quiz." />97 ) : (98 <div className="overflow-x-auto">99 <table className="w-full text-[13px]">100 <thead>101 <tr className="border-b border-app text-left text-[12px] text-muted">102 <th className="py-2 pr-3 font-medium">Question</th>103 <th className="py-2 pr-3 font-medium">Concept</th>104 <th className="py-2 pr-3 text-right font-medium">Tentatives</th>105 <th className="py-2 text-right font-medium">Réussite</th>106 </tr>107 </thead>108 <tbody>109 {data.hardestQuestions.map((q) => {110 const pct = q.success_pct ?? 0;111 return (112 <tr key={q.id} className="border-b border-app last:border-0 align-top">113 <td className="max-w-[280px] py-2 pr-3 text-fg">114 <span className="line-clamp-2" title={q.question}>{q.question}</span>115 </td>116 <td className="py-2 pr-3 text-muted">{q.concept ?? "—"}</td>117 <td className="py-2 pr-3 text-right tabular-nums text-muted">{fmtInt(q.attempts)}</td>118 <td className="py-2 text-right">119 <span className="inline-flex items-center gap-2">120 <span className="h-1.5 w-14 overflow-hidden rounded-full bg-surface-2 dark:bg-brand-900/60">121 <span className={cn("block h-full rounded-full", successTone(pct))} style={{ width: `${pct}%` }} />122 </span>123 <span className="w-10 text-right tabular-nums text-fg">{Math.round(pct)} %</span>124 </span>125 </td>126 </tr>127 );128 })}129 </tbody>130 </table>131 </div>132 )}133 </Card>134135 {/* Erreurs récurrentes */}136 <Card className="p-5">137 <SectionTitle sub="Entrées du carnet d'erreurs, regroupées par concept">Erreurs récurrentes par concept</SectionTitle>138 {data.commonErrors.length === 0 ? (139 <EmptyState icon={<BookOpen />} title="Aucune erreur récurrente" description="Le carnet d'erreurs ne contient pas encore de tendances significatives." />140 ) : (141 <div>142 {data.commonErrors.map((e, i) => (143 <HBarRow144 key={i}145 label={e.concept ?? "Concept non associé"}146 value={e.n / maxErrors}147 display={`${fmtInt(e.n)} erreur${e.n > 1 ? "s" : ""}`}148 barClass="bg-amber-500"149 />150 ))}151 </div>152 )}153 </Card>154155 {/* Examens blancs */}156 <Card className="p-5">157 <SectionTitle sub="Tentatives complétées seulement — moyenne masquée sous le seuil">Examens blancs</SectionTitle>158 {data.examStats.length === 0 ? (159 <EmptyState icon={<BookOpen />} title="Aucun examen blanc" description="Aucun examen blanc n'a été créé pour ce cours." />160 ) : (161 <div className="overflow-x-auto">162 <table className="w-full text-[13px]">163 <thead>164 <tr className="border-b border-app text-left text-[12px] text-muted">165 <th className="py-2 pr-3 font-medium">Examen</th>166 <th className="py-2 pr-3 text-right font-medium">Tentatives</th>167 <th className="py-2 text-right font-medium">Moyenne</th>168 </tr>169 </thead>170 <tbody>171 {data.examStats.map((e, i) => (172 <tr key={i} className="border-b border-app last:border-0">173 <td className="py-2 pr-3 font-medium text-fg">{e.title}</td>174 <td className="py-2 pr-3 text-right tabular-nums text-muted">{fmtInt(e.attempts)}</td>175 <td className="py-2 text-right tabular-nums">176 {e.avg_pct == null ? (177 <span className="italic text-muted">— moins de 3 étudiants</span>178 ) : (179 <span className="text-fg">{e.avg_pct.toLocaleString("fr-CA")} %</span>180 )}181 </td>182 </tr>183 ))}184 </tbody>185 </table>186 </div>187 )}188 </Card>189190 {/* Rétroactions par jour */}191 <Card className="p-5 lg:col-span-2">192 <SectionTitle sub="Rétroactions sur les réponses de l'assistant, 30 derniers jours">Rétroactions 👍 / 👎 par jour</SectionTitle>193 <FeedbackChart feedback={data.feedback} />194 </Card>195196 {/* Signalements */}197 <Card className="p-5 lg:col-span-2">198 <SectionTitle sub="Réponses signalées par les étudiant·e·s (30 plus récentes, tous cours confondus)">199 Signalements200 </SectionTitle>201 {data.flagged.length === 0 ? (202 <EmptyState icon={<Flag />} title="Aucun signalement" description="Aucune réponse n'a été signalée." />203 ) : (204 <>205 <ul className="space-y-2.5">206 {data.flagged.map((f) => (207 <li key={f.id} className="rounded-xl border border-app p-3.5">208 <div className="mb-1.5 flex flex-wrap items-center gap-2">209 <Badge tone={f.resolved ? "green" : "amber"}>{f.resolved ? "Résolu" : "Ouvert"}</Badge>210 {f.reason && <Badge tone="neutral">{f.reason}</Badge>}211 <span className="text-[11.5px] tabular-nums text-muted">{fmtDate(f.created_at)}</span>212 </div>213 <p className="text-[13px] leading-relaxed text-muted">{f.message_preview}…</p>214 </li>215 ))}216 </ul>217 <p className="mt-3 text-[12px] italic text-muted">218 La résolution des signalements depuis cette interface n'est pas encore offerte par l'API — marquez-les résolus219 directement en base de données pour l'instant.220 </p>221 </>222 )}223 </Card>224 </div>225 )}226 </div>227 );228}229230// Graphique divergent : 👍 au-dessus de la ligne de base (bleu), 👎 en dessous (rouge).231function FeedbackChart({ feedback }: { feedback: { day: string; up: number; down: number }[] }) {232 const days = useMemo(() => {233 const out: { day: string; up: number; down: number }[] = [];234 for (let i = 29; i >= 0; i--) {235 const d = new Date();236 d.setDate(d.getDate() - i);237 const key = d.toISOString().slice(0, 10);238 const row = feedback.find((r) => r.day === key);239 out.push({ day: key, up: row?.up ?? 0, down: row?.down ?? 0 });240 }241 return out;242 }, [feedback]);243244 const max = Math.max(...days.map((d) => Math.max(d.up, d.down)), 0);245 if (max === 0) {246 return <p className="py-8 text-center text-sm text-muted">Aucune rétroaction sur les 30 derniers jours.</p>;247 }248249 return (250 <div>251 <div className="mb-2 flex items-center gap-4 text-[12px] text-muted">252 <span className="inline-flex items-center gap-1.5">253 <span className="h-2.5 w-2.5 rounded-sm bg-brand-500" />254 <ThumbsUp size={12} /> Positives255 </span>256 <span className="inline-flex items-center gap-1.5">257 <span className="h-2.5 w-2.5 rounded-sm bg-red-600" />258 <ThumbsDown size={12} /> Négatives259 </span>260 </div>261 <div className="flex items-stretch gap-[3px]" style={{ height: 160 }} role="img" aria-label="Rétroactions positives et négatives par jour">262 {days.map((d) => (263 <div key={d.day} className="group relative flex min-w-0 flex-1 flex-col">264 <div className="flex flex-1 flex-col justify-end">265 <div266 className="w-full rounded-t-[4px] bg-brand-500 transition-opacity group-hover:opacity-75"267 style={{ height: `${(d.up / max) * 100}%` }}268 />269 </div>270 <div className="my-[1px] h-px w-full" style={{ background: "var(--border)" }} />271 <div className="flex flex-1 flex-col justify-start">272 <div273 className="w-full rounded-b-[4px] bg-red-600 transition-opacity group-hover:opacity-75"274 style={{ height: `${(d.down / max) * 100}%` }}275 />276 </div>277 <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 whitespace-nowrap rounded-lg border border-app bg-card px-2.5 py-1.5 text-[12px] shadow-lg group-hover:block">278 <span className="text-muted">{d.day.slice(5)}</span>{" "}279 <span className="font-semibold tabular-nums text-fg">👍 {d.up}</span>{" "}280 <span className="font-semibold tabular-nums text-fg">👎 {d.down}</span>281 </div>282 </div>283 ))}284 </div>285 <div className="mt-1.5 flex justify-between text-[11px] text-muted">286 <span>{days[0]?.day.slice(5)}</span>287 <span>{days[days.length - 1]?.day.slice(5)}</span>288 </div>289 </div>290 );291}292