SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
13.7 KB · 292 lines tsx
Raw Blame History
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