"use client"; // Cours & contenu : documents ingérés regroupés par cours puis espace, // relance de l'ingestion (normale ou --force) et historique des exécutions. import { useMemo, useState } from "react"; import { ChevronDown, ChevronRight, FileText, RefreshCw } from "lucide-react"; import { PageHeader } from "@/components/app-shell"; import { Badge, Button, Card, EmptyState, Skeleton, Spinner, cn } from "@/components/ui"; import { ErrorBanner, SectionTitle, fmtDate, fmtInt, postJson, useFetchJson } from "./shared"; type Doc = { id: number; course_code: string; space: string; filename: string; doc_type: string; title: string; week: number | null; status: string; error: string | null; visible_to_students: number; ingested_at: string | null; chunk_count: number; }; type Run = { id: number; started_at: string; finished_at: string | null; triggered_by: string; files_scanned: number; files_ingested: number; files_skipped: number; chunks_created: number; status: string; report: string | null; }; type IngestResult = { ok: boolean; runId: number; scanned: number; ingested: number; skipped: number; chunks: number; errors: { path: string; error: string }[]; report: string; }; const DOC_TYPE_LABEL: Record = { slides: "Diapositives", plan: "Plan de cours", exam: "Examen", glossary: "Glossaire", "aide-memoire": "Aide-mémoire", markdown: "Document", }; function SpaceBadge({ space }: { space: string }) { if (space === "instructor-private") return Privé prof; if (space.startsWith("official-")) return Officiel; return {space}; } function statusTone(status: string): "green" | "red" | "amber" { const s = status.toLowerCase(); if (s === "ok" || s.includes("success") || s.includes("succès") || s.includes("completed")) return "green"; if (s.includes("error") || s.includes("fail") || s.includes("erreur") || s.includes("échec")) return "red"; return "amber"; } export function AdminCourses() { const { data, error, loading, reload } = useFetchJson<{ runs: Run[]; documents: Doc[] }>("/api/admin/ingest"); const [running, setRunning] = useState(null); const [runError, setRunError] = useState(null); const [result, setResult] = useState(null); const [expandedRun, setExpandedRun] = useState(null); const [showResultReport, setShowResultReport] = useState(false); const grouped = useMemo(() => { const byCourse = new Map>(); for (const d of data?.documents ?? []) { if (!byCourse.has(d.course_code)) byCourse.set(d.course_code, new Map()); const spaces = byCourse.get(d.course_code)!; if (!spaces.has(d.space)) spaces.set(d.space, []); spaces.get(d.space)!.push(d); } return byCourse; }, [data]); async function launch(force: boolean) { setRunning(force ? "force" : "normal"); setRunError(null); setResult(null); setShowResultReport(false); try { const r = await postJson(`/api/admin/ingest${force ? "?force=1" : ""}`); setResult(r); await reload(); } catch (e) { setRunError(e instanceof Error ? e.message : "L'ingestion a échoué."); } finally { setRunning(null); } } const actions = (
); return (
{running && (

{running === "force" ? "Réindexation complète en cours" : "Ingestion en cours"} — cela peut prendre de 1 à 3 minutes. Merci de patienter, ne quittez pas cette page.

)} {runError &&
} {result && (
0 ? "amber" : "green"}> {result.errors.length > 0 ? "Terminée avec avertissements" : "Ingestion terminée"} Fichiers examinés : {fmtInt(result.scanned)} Ingérés : {fmtInt(result.ingested)} Ignorés (inchangés) : {fmtInt(result.skipped)} Fragments créés : {fmtInt(result.chunks)}
{result.errors.length > 0 && (
    {result.errors.map((e, i) => (
  • {e.path} — {e.error}
  • ))}
)} {showResultReport && (
              {result.report || "(rapport vide)"}
            
)}
)} {loading ? (
) : error ? ( ) : grouped.size === 0 ? ( } title="Aucun document indexé" description="Lancez l'ingestion pour indexer le matériel des cours (diapositives, plans, glossaires…)." action={} /> ) : (
{[...grouped.entries()].map(([course, spaces]) => (

{course}

{[...spaces.entries()].map(([space, docs]) => (
{fmtInt(docs.length)} document{docs.length > 1 ? "s" : ""} ·{" "} {fmtInt(docs.reduce((s, d) => s + d.chunk_count, 0))} fragments
{docs.map((d) => ( ))}
Fichier Type Semaine Fragments Statut Ingéré le

{d.title || d.filename}

{d.filename}

{DOC_TYPE_LABEL[d.doc_type] ?? d.doc_type} {d.week ?? "—"} {fmtInt(d.chunk_count)} {d.status === "ok" ? "OK" : d.status} {d.error &&

{d.error}

}
{fmtDate(d.ingested_at)}
))}
))}
)} {!loading && !error && (
Historique des exécutions {(data?.runs.length ?? 0) === 0 ? (

Aucune exécution enregistrée.

) : (
{(data?.runs ?? []).map((r) => ( setExpandedRun(expandedRun === r.id ? null : r.id)} /> ))}
# Démarrée Terminée Déclenchée par Examinés Ingérés Ignorés Fragments Statut Rapport
)}
)}
); } function RunRow({ run, expanded, onToggle }: { run: Run; expanded: boolean; onToggle: () => void }) { return ( <> {run.id} {fmtDate(run.started_at)} {run.finished_at ? fmtDate(run.finished_at) : "—"} {run.triggered_by} {fmtInt(run.files_scanned)} {fmtInt(run.files_ingested)} {fmtInt(run.files_skipped)} {fmtInt(run.chunks_created)} {run.status} {expanded && (
              {run.report || "(aucun rapport)"}
            
)} ); }