TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Cahier d'erreurs : filtres par statut, cartes dépliables (question, votre réponse,3// correction, explication), changement de statut et suppression.4import { useCallback, useEffect, useMemo, useState } from "react";5import { CheckCircle2, ChevronDown, NotebookPen, Trash2 } from "lucide-react";6import { Badge, Card, EmptyState, Skeleton, Tabs, cn } from "@/components/ui";7import { Markdown } from "@/components/chat/markdown";8import { ErrorBanner, fetchJson, fmtDate } from "./shared";910type ErrorEntry = {11 id: number; course_code: string; concept_id: number | null; concept_name: string | null;12 question: string; given_answer: string; correction: string; explanation: string;13 source: string; status: "a-revoir" | "comprise" | "maitrisee"; created_at: string;14};1516const STATUS_META: Record<ErrorEntry["status"], { label: string; tone: "red" | "amber" | "green" }> = {17 "a-revoir": { label: "À revoir", tone: "red" },18 comprise: { label: "Comprise", tone: "amber" },19 maitrisee: { label: "Maîtrisée", tone: "green" },20};21const SOURCE_LABELS: Record<string, string> = { quiz: "Quiz", exam: "Examen blanc", chat: "Chat", correction: "Correction" };2223export function ErrorsApp({ course }: { course: string }) {24 const upper = course.toUpperCase();25 const [entries, setEntries] = useState<ErrorEntry[] | null>(null);26 const [filter, setFilter] = useState<string>("toutes");27 const [open, setOpen] = useState<Record<number, boolean>>({});28 const [error, setError] = useState<string | null>(null);2930 const load = useCallback(() => {31 setError(null);32 fetchJson<{ errors: ErrorEntry[] }>(`/api/learning/errors?course=${upper}`)33 .then((d) => setEntries(d.errors))34 .catch((e) => { setEntries([]); setError(e instanceof Error ? e.message : "Erreur de chargement."); });35 }, [upper]);3637 useEffect(() => { load(); }, [load]);3839 async function patch(id: number, body: { status?: ErrorEntry["status"]; remove?: boolean }) {40 setError(null);41 try {42 const res = await fetch("/api/learning/errors", {43 method: "PATCH",44 headers: { "Content-Type": "application/json" },45 body: JSON.stringify({ id, ...body }),46 });47 if (!res.ok) {48 const d = await res.json().catch(() => ({}));49 throw new Error((d as { error?: string }).error ?? `Erreur ${res.status}`);50 }51 setEntries((es) =>52 es53 ? body.remove54 ? es.filter((e) => e.id !== id)55 : es.map((e) => (e.id === id && body.status ? { ...e, status: body.status } : e))56 : es57 );58 } catch (e) {59 setError(e instanceof Error ? e.message : "Erreur d'enregistrement.");60 }61 }6263 const filtered = useMemo(() => {64 if (!entries) return [];65 if (filter === "toutes") return entries;66 return entries.filter((e) => e.status === filter);67 }, [entries, filter]);6869 const counts = useMemo(() => {70 const c = { toutes: entries?.length ?? 0, "a-revoir": 0, comprise: 0, maitrisee: 0 };71 for (const e of entries ?? []) c[e.status]++;72 return c;73 }, [entries]);7475 return (76 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full">77 <div className="mb-5">78 <h2 className="text-lg font-bold text-fg">Cahier d'erreurs</h2>79 <p className="text-[13px] text-muted">80 Vos erreurs de quiz et d'examens, à retravailler jusqu'à les maîtriser — la forme de révision la plus rentable.81 </p>82 </div>8384 <Tabs85 className="mb-4"86 active={filter}87 onChange={setFilter}88 tabs={[89 { key: "toutes", label: `Toutes (${counts.toutes})` },90 { key: "a-revoir", label: `À revoir (${counts["a-revoir"]})` },91 { key: "comprise", label: `Comprises (${counts.comprise})` },92 { key: "maitrisee", label: `Maîtrisées (${counts.maitrisee})` },93 ]}94 />9596 {error && <ErrorBanner message={error} className="mb-4" />}9798 {entries === null ? (99 <div className="space-y-3">100 <Skeleton className="h-20 w-full" />101 <Skeleton className="h-20 w-full" />102 <Skeleton className="h-20 w-full" />103 </div>104 ) : filtered.length === 0 ? (105 <EmptyState106 icon={filter === "toutes" ? <CheckCircle2 /> : <NotebookPen />}107 title={filter === "toutes" ? "Aucune erreur enregistrée" : "Rien dans ce filtre"}108 description={109 filter === "toutes"110 ? "Vos réponses incorrectes aux quiz et examens blancs apparaîtront ici automatiquement."111 : "Changez de filtre pour voir les autres entrées."112 }113 />114 ) : (115 <div className="space-y-2.5">116 {filtered.map((e) => {117 const meta = STATUS_META[e.status];118 const isOpen = !!open[e.id];119 return (120 <Card key={e.id} className="overflow-hidden animate-fade-up">121 <button122 onClick={() => setOpen((o) => ({ ...o, [e.id]: !o[e.id] }))}123 aria-expanded={isOpen}124 className="w-full flex items-start gap-3 px-4 py-3.5 text-left"125 >126 <div className="min-w-0 flex-1">127 <p className={cn("text-sm text-fg font-medium", !isOpen && "line-clamp-2")}>{e.question}</p>128 <div className="flex flex-wrap gap-1.5 mt-1.5">129 <Badge tone={meta.tone}>{meta.label}</Badge>130 {e.concept_name && <Badge tone="brand">{e.concept_name}</Badge>}131 <Badge tone="neutral">{SOURCE_LABELS[e.source] ?? e.source}</Badge>132 <span className="text-[11.5px] text-muted self-center">{fmtDate(e.created_at)}</span>133 </div>134 </div>135 <ChevronDown size={16} className={cn("text-muted shrink-0 mt-1 transition-transform", isOpen && "rotate-180")} />136 </button>137 {isOpen && (138 <div className="px-4 pb-4 space-y-3 border-t border-app pt-3.5">139 <div>140 <p className="text-[11.5px] font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-1">Votre réponse</p>141 <p className="text-sm text-fg whitespace-pre-wrap bg-red-500/5 border border-red-500/20 rounded-lg px-3 py-2">142 {e.given_answer || "—"}143 </p>144 </div>145 <div>146 <p className="text-[11.5px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-1">Correction</p>147 <div className="bg-emerald-500/5 border border-emerald-500/20 rounded-lg px-3 py-2">148 <Markdown content={e.correction} />149 </div>150 </div>151 {e.explanation && (152 <div>153 <p className="text-[11.5px] font-semibold text-muted uppercase tracking-wide mb-1">Explication</p>154 <Markdown content={e.explanation} />155 </div>156 )}157 <div className="flex flex-wrap items-center gap-1.5 pt-1">158 {(Object.keys(STATUS_META) as ErrorEntry["status"][]).map((s) => (159 <button160 key={s}161 onClick={() => patch(e.id, { status: s })}162 disabled={e.status === s}163 className={cn(164 "px-3 h-8 rounded-lg text-[12.5px] font-medium border transition-colors",165 e.status === s166 ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40 text-brand-700 dark:text-brand-300 cursor-default"167 : "border-app bg-card text-muted hover:text-fg hover:border-brand-300"168 )}169 >170 {STATUS_META[s].label}171 </button>172 ))}173 <button174 onClick={() => { if (confirm("Supprimer définitivement cette entrée ?")) patch(e.id, { remove: true }); }}175 title="Supprimer"176 aria-label="Supprimer l'entrée"177 className="ml-auto p-2 rounded-lg text-muted hover:text-red-500 transition-colors"178 >179 <Trash2 size={15} />180 </button>181 </div>182 </div>183 )}184 </Card>185 );186 })}187 </div>188 )}189 </main>190 );191}192