SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
11.1 KB · 82 lines tsx
Raw Blame History
1"use client";23import { useCallback, useEffect, useState } from "react";4import { cls, dateTimeFr, relTime } from "@/lib/format";5import { Badge, Card, SectionHeader } from "./ui";67const KEY = "qc26-admin-token";8type Json = Record<string, unknown>;910export function Admin() {11  const [token, setToken] = useState("");12  const [ok, setOk] = useState<boolean | null>(null);13  const [tab, setTab] = useState<"war-room" | "jobs" | "review" | "audit" | "actions">("war-room");14  const [data, setData] = useState<Json | null>(null);15  const [log, setLog] = useState<string[]>([]);16  const [busy, setBusy] = useState(false);17  useEffect(() => { const t = localStorage.getItem(KEY); if (t) setToken(t); }, []);18  const call = useCallback(async (path: string, method = "GET", body?: Json) => {19    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" });20    if (r.status === 401) { setOk(false); throw new Error("401"); }21    return r.json();22  }, [token]);23  const login = async () => { try { await call("ping"); setOk(true); localStorage.setItem(KEY, token); } catch { setOk(false); } };24  useEffect(() => { if (token && ok === null) login(); }, [token]); // eslint-disable-line react-hooks/exhaustive-deps25  const load = useCallback(async () => { if (!ok) return; if (tab === "actions") return; try { setData(await call(tab)); } catch { /* */ } }, [ok, tab, call]);26  useEffect(() => { load(); const t = setInterval(load, tab === "war-room" ? 5000 : 30000); return () => clearInterval(t); }, [load, tab]);27  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); } };2829  if (!ok) return (30    <div className="max-w-sm mx-auto card card-lg p-6 mt-10"><div className="eyebrow">Administration QC26</div><h1 className="text-[22px] font-bold mt-1">Jeton requis</h1><input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="Jeton d'administration" className="card w-full px-3 py-2 mt-4 outline-none" /><button onClick={login} className="mt-3 w-full rounded-[8px] bg-brand text-white py-2 font-semibold">Entrer</button>{ok === false && <div className="text-live text-[13px] mt-2">Jeton refusé.</div>}</div>31  );32  return (33    <div className="space-y-6">34      <div className="flex items-end justify-between flex-wrap gap-3"><div><div className="eyebrow">Backoffice · privé</div><h1 className="display text-[32px] md:text-[40px] mt-1">Administration</h1></div><nav className="flex gap-1 flex-wrap">{(["war-room", "jobs", "review", "audit", "actions"] as const).map((t) => <button key={t} onClick={() => setTab(t)} className={cls("rounded-full px-3 py-1.5 text-[13px] border", tab === t ? "bg-brand text-white border-brand" : "border-border")}>{t}</button>)}</nav></div>35      {tab === "war-room" && data && <WarRoom d={data} />}36      {tab === "jobs" && data && <Card pad={false}><table className="data-table"><thead><tr><th>Travail</th><th>Statut</th><th>Début</th><th>Fin</th><th className="r">n</th><th>Message / détail</th></tr></thead><tbody>{(data.jobs as Json[] ?? []).map((j) => <tr key={String(j.id)}><td>{String(j.kind)}</td><td><Badge tone={j.status === "ok" ? "ok" : j.status === "running" ? "accent" : "warn"}>{String(j.status)}</Badge></td><td className="num">{dateTimeFr(String(j.startedAt))}</td><td className="num">{j.finishedAt ? dateTimeFr(String(j.finishedAt)) : "—"}</td><td className="r num">{String(j.items)}</td><td className="text-[12px] text-ink-3 max-w-[420px] truncate">{String(j.message ?? "")} {j.detail ? JSON.stringify(j.detail).slice(0, 200) : ""}</td></tr>)}</tbody></table></Card>}37      {tab === "review" && data && <ReviewQueue d={data} call={call} reload={load} />}38      {tab === "audit" && data && <Card pad={false}><table className="data-table"><thead><tr><th>Quand</th><th>Acteur</th><th>Entité</th><th>Champ</th><th>Ancien</th><th>Nouveau</th><th>Raison</th></tr></thead><tbody>{(data.audit as Json[] ?? []).map((a) => <tr key={String(a.id)}><td className="num whitespace-nowrap">{dateTimeFr(String(a.ts))}</td><td>{String(a.actor)}</td><td>{String(a.entity)} #{String(a.entityId)}</td><td>{String(a.field ?? "")}</td><td className="text-[11.5px] text-ink-3 max-w-[160px] truncate">{String(a.old ?? "")}</td><td className="text-[11.5px] max-w-[160px] truncate">{String(a.new ?? "")}</td><td className="text-[12px] text-ink-2">{String(a.reason ?? "")}</td></tr>)}</tbody></table></Card>}39      {tab === "actions" && (40        <div className="grid md:grid-cols-2 gap-4">41          <Card><SectionHeader title="Actions" />42            <div className="grid grid-cols-2 gap-2 text-[13px]">43              {[["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]) => <button key={p} disabled={busy} onClick={() => act(l, p)} className="card px-3 py-2 text-left hover:bg-surface-2 disabled:opacity-40">{l}</button>)}44            </div>45          </Card>46          <Card><SectionHeader title="Journal" /><div className="mono text-[11.5px] space-y-1 max-h-[420px] overflow-auto">{log.map((l, i) => <div key={i} className="break-all">{l}</div>)}{log.length === 0 && <div className="text-ink-3">Aucune action.</div>}</div></Card>47        </div>48      )}49    </div>50  );51}5253function WarRoom({ d }: { d: Json }) {54  const feed = d.feed as Json; const nc = d.nowcast as Json | null; const jobs = d.jobs as Record<string, Json>;55  return (56    <div className="space-y-4">57      <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">58        {[["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]) => <Card key={t}><div className="eyebrow">{t}</div><div className="num display text-[32px] mt-1">{v}</div><div className="text-[12px] text-ink-2">{s}</div></Card>)}59      </div>60      <div className="grid md:grid-cols-2 gap-4">61        <Card><div className="eyebrow mb-2">Nowcast run</div>{nc ? <pre className="mono text-[11.5px] whitespace-pre-wrap">{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)}</pre> : <div className="text-ink-3 text-[13px]">Aucun nowcast.</div>}</Card>62        <Card><div className="eyebrow mb-2">Planificateur</div><table className="data-table"><tbody>{Object.entries(jobs ?? {}).map(([k, v]) => <tr key={k}><td>{k}</td><td><Badge tone={v.status === "ok" ? "ok" : "warn"}>{String(v.status)}</Badge></td><td className="num text-ink-2">{relTime(String(v.at))}</td><td className="text-[11.5px] text-ink-3 max-w-[260px] truncate">{typeof v.detail === "string" ? v.detail : JSON.stringify(v.detail ?? "")}</td></tr>)}</tbody></table>63          <div className="mt-3 text-[12.5px]"><span className="eyebrow">Erreurs</span><ul className="mt-1 space-y-1">{((d.errors as Json[]) ?? []).map((e, i) => <li key={i} className="text-live">{String(e.kind)} — {String(e.message)}</li>)}{((d.errors as Json[]) ?? []).length === 0 && <li className="text-ink-3">Aucune.</li>}</ul></div>64          <div className="mt-2 text-[12px] text-ink-3">Simulation : {String(d.simulation)}</div></Card>65      </div>66    </div>67  );68}6970function ReviewQueue({ d, call, reload }: { d: Json; call: (p: string, m?: string, b?: Json) => Promise<Json>; reload: () => void }) {71  const [reason, setReason] = useState("Révision manuelle");72  const polls = (d.polls as Json[]) ?? [], props = (d.proposals as Json[]) ?? [], pos = (d.positions as Json[]) ?? [];73  return (74    <div className="space-y-4">75      <Card><div className="flex items-center gap-2 text-[13px]"><span className="eyebrow">Raison des corrections</span><input value={reason} onChange={(e) => setReason(e.target.value)} className="card px-2.5 py-1.5 flex-1" /></div></Card>76      <Card pad={false}><div className="px-4 py-2 eyebrow">Sondages non validés ({polls.length})</div><table className="data-table"><tbody>{polls.map((p) => <tr key={String(p.id)}><td className="font-medium">{String(p.id)}</td><td className="text-[12px] text-ink-3">{String(p.tier)} · {JSON.stringify(p.notes).slice(0, 120)}</td><td className="r"><button className="link text-[12.5px] mr-3" onClick={async () => { await call(`polls/${p.id}`, "PATCH", { validated: true, excluded: false, reason }); reload(); }}>valider</button><button className="link text-[12.5px]" onClick={async () => { await call(`polls/${p.id}`, "PATCH", { excluded: true, reason }); reload(); }}>exclure</button></td></tr>)}</tbody></table></Card>77      <Card pad={false}><div className="px-4 py-2 eyebrow">Propositions en révision ({props.length})</div><table className="data-table"><tbody>{props.slice(0, 60).map((p) => <tr key={String(p.id)}><td className="text-[12.5px]"><b>{String(p.party).toUpperCase()}</b> · {String(p.topic)} — {String(p.proposal)}<div className="text-ink-3 italic">« {String(p.quote).slice(0, 160)} »</div></td><td className="r whitespace-nowrap"><button className="link text-[12.5px] mr-3" onClick={async () => { await call(`proposals/${p.id}`, "PATCH", { status: "accepted", reason }); reload(); }}>accepter</button><button className="link text-[12.5px]" onClick={async () => { await call(`proposals/${p.id}`, "PATCH", { status: "rejected", reason }); reload(); }}>rejeter</button></td></tr>)}</tbody></table></Card>78      <Card pad={false}><div className="px-4 py-2 eyebrow">Positions boussole en révision ({pos.length})</div><table className="data-table"><tbody>{pos.map((p) => <tr key={String(p.id)}><td className="text-[12.5px]"><b>{String(p.party).toUpperCase()}</b> · {String(p.question)} → {String(p.value)} ({Math.round(Number(p.confidence) * 100)} %)<div className="text-ink-3">{String(p.rationale ?? "")}</div></td><td className="r whitespace-nowrap"><button className="link text-[12.5px] mr-3" onClick={async () => { await call(`positions/${p.id}`, "PATCH", { status: "extracted", reason }); reload(); }}>confirmer</button><button className="link text-[12.5px]" onClick={async () => { await call(`positions/${p.id}`, "PATCH", { status: "pending", reason }); reload(); }}>retirer</button></td></tr>)}</tbody></table></Card>79    </div>80  );81}82