"use client"; import { useCallback, useEffect, useState } from "react"; import { cls, dateTimeFr, relTime } from "@/lib/format"; import { Badge, Card, SectionHeader } from "./ui"; const KEY = "qc26-admin-token"; type Json = Record; export function Admin() { const [token, setToken] = useState(""); const [ok, setOk] = useState(null); const [tab, setTab] = useState<"war-room" | "jobs" | "review" | "audit" | "actions">("war-room"); const [data, setData] = useState(null); const [log, setLog] = useState([]); const [busy, setBusy] = useState(false); useEffect(() => { const t = localStorage.getItem(KEY); if (t) setToken(t); }, []); const call = useCallback(async (path: string, method = "GET", body?: Json) => { const r = await fetch(`/api/admin/${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined, cache: "no-store" }); if (r.status === 401) { setOk(false); throw new Error("401"); } return r.json(); }, [token]); const login = async () => { try { await call("ping"); setOk(true); localStorage.setItem(KEY, token); } catch { setOk(false); } }; useEffect(() => { if (token && ok === null) login(); }, [token]); // eslint-disable-line react-hooks/exhaustive-deps const load = useCallback(async () => { if (!ok) return; if (tab === "actions") return; try { setData(await call(tab)); } catch { /* */ } }, [ok, tab, call]); useEffect(() => { load(); const t = setInterval(load, tab === "war-room" ? 5000 : 30000); return () => clearInterval(t); }, [load, tab]); const act = async (label: string, path: string, body?: Json) => { setBusy(true); setLog((l) => [`${new Date().toLocaleTimeString()} ▶ ${label}…`, ...l]); try { const r = await call(path, "POST", body); setLog((l) => [`${new Date().toLocaleTimeString()} ✓ ${label} : ${JSON.stringify(r).slice(0, 300)}`, ...l]); } catch (e) { setLog((l) => [`${new Date().toLocaleTimeString()} ✗ ${label} : ${String(e)}`, ...l]); } finally { setBusy(false); } }; if (!ok) return (
Administration QC26

Jeton requis

setToken(e.target.value)} placeholder="Jeton d'administration" className="card w-full px-3 py-2 mt-4 outline-none" />{ok === false &&
Jeton refusé.
}
); return (
Backoffice · privé

Administration

{tab === "war-room" && data && } {tab === "jobs" && data && {(data.jobs as Json[] ?? []).map((j) => )}
TravailStatutDébutFinnMessage / détail
{String(j.kind)}{String(j.status)}{dateTimeFr(String(j.startedAt))}{j.finishedAt ? dateTimeFr(String(j.finishedAt)) : "—"}{String(j.items)}{String(j.message ?? "")} {j.detail ? JSON.stringify(j.detail).slice(0, 200) : ""}
} {tab === "review" && data && } {tab === "audit" && data && {(data.audit as Json[] ?? []).map((a) => )}
QuandActeurEntitéChampAncienNouveauRaison
{dateTimeFr(String(a.ts))}{String(a.actor)}{String(a.entity)} #{String(a.entityId)}{String(a.field ?? "")}{String(a.old ?? "")}{String(a.new ?? "")}{String(a.reason ?? "")}
} {tab === "actions" && (
{[["Recalculer le forecast", "forecast/run"], ["Veille sondages (watch)", "polls/watch"], ["Vérifier 10 documents", "polls/verify?limit=10"], ["Ré-amorcer l'index", "polls/bootstrap"], ["Rafraîchir données officielles", "official/refresh"], ["Sync documents partis", "documents/sync"], ["Positions boussole", "compass/positions"], ["Simulation : démarrer", "live/simulation/start"], ["Simulation : arrêter", "live/simulation/stop"], ["Simulation : réinitialiser", "live/simulation/reset"], ["Flux live : lire maintenant", "live/poll"]].map(([l, p]) => )}
{log.map((l, i) =>
{l}
)}{log.length === 0 &&
Aucune action.
}
)}
); } function WarRoom({ d }: { d: Json }) { const feed = d.feed as Json; const nc = d.nowcast as Json | null; const jobs = d.jobs as Record; return (
{[["Official feed", `${feed?.status ?? "—"}`, feed?.lastOkAt ? `ok ${relTime(String(feed.lastOkAt))}` : "jamais"], ["Last ingestion", d.lastIngestion ? relTime(String(d.lastIngestion)) : "—", `${d.snapshots} snapshots`], ["Ridings reporting", String(d.reporting ?? 0), d.quarantine ? "QUARANTAINE" : "flux valide"], ["Connected clients", String(d.connectedClients ?? 0), `DB ${d.dbLatencyMs} ms`]].map(([t, v, s]) =>
{t}
{v}
{s}
)}
Nowcast run
{nc ?
{JSON.stringify({ id: nc.id, ts: nc.ts, reporting: nc.reporting, coverage: nc.coverage, p_minority: nc.p_minority, parties: nc.parties }, null, 1).slice(0, 2500)}
:
Aucun nowcast.
}
Planificateur
{Object.entries(jobs ?? {}).map(([k, v]) => )}
{k}{String(v.status)}{relTime(String(v.at))}{typeof v.detail === "string" ? v.detail : JSON.stringify(v.detail ?? "")}
Erreurs
    {((d.errors as Json[]) ?? []).map((e, i) =>
  • {String(e.kind)} — {String(e.message)}
  • )}{((d.errors as Json[]) ?? []).length === 0 &&
  • Aucune.
  • }
Simulation : {String(d.simulation)}
); } function ReviewQueue({ d, call, reload }: { d: Json; call: (p: string, m?: string, b?: Json) => Promise; reload: () => void }) { const [reason, setReason] = useState("Révision manuelle"); const polls = (d.polls as Json[]) ?? [], props = (d.proposals as Json[]) ?? [], pos = (d.positions as Json[]) ?? []; return (
Raison des corrections setReason(e.target.value)} className="card px-2.5 py-1.5 flex-1" />
Sondages non validés ({polls.length})
{polls.map((p) => )}
{String(p.id)}{String(p.tier)} · {JSON.stringify(p.notes).slice(0, 120)}
Propositions en révision ({props.length})
{props.slice(0, 60).map((p) => )}
{String(p.party).toUpperCase()} · {String(p.topic)} — {String(p.proposal)}
« {String(p.quote).slice(0, 160)} »
Positions boussole en révision ({pos.length})
{pos.map((p) => )}
{String(p.party).toUpperCase()} · {String(p.question)} → {String(p.value)} ({Math.round(Number(p.confidence) * 100)} %)
{String(p.rationale ?? "")}
); }