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%
8.5 KB · 192 lines tsx
Raw Blame History
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