SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
14.3 KB · 135 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : console d'administration (/cout/admin) — jeton côté client envoyé en2// Authorization: Bearer aux routes /api/admin/cost/*. Connecteurs, anomalies, mappings IA, benchmarks, usage IA.3"use client";4import { useEffect, useState } from "react";5import Link from "next/link";6import { useLang } from "@/components/LangContext";7import { fmtDate } from "./Provenance";89type J = Record<string, unknown>;10const KEY = "vrai-prix-cout-admin-token";1112export default function AdminConsole() {13  const { lang } = useLang();14  const fr = lang === "fr";15  const [token, setToken] = useState("");16  const [ready, setReady] = useState(false);17  const [tab, setTab] = useState<"connectors" | "anomalies" | "mappings" | "benchmarks" | "usage">("connectors");18  const [data, setData] = useState<J | null>(null);19  const [err, setErr] = useState<string | null>(null);20  const [busy, setBusy] = useState<string | null>(null);21  const [csv, setCsv] = useState("");22  const [log, setLog] = useState<string[]>([]);2324  useEffect(() => { const id = setTimeout(() => { try { setToken(window.sessionStorage.getItem(KEY) ?? ""); } catch { /* ignore */ } setReady(true); }, 0); return () => clearTimeout(id); }, []);2526  const call = async (path: string, init?: RequestInit): Promise<J> => {27    const r = await fetch(`/api/admin/cost/${path}`, { ...init, headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });28    const d = (await r.json().catch(() => ({}))) as J;29    if (!r.ok) throw new Error(String(d.error ?? `${r.status} ${r.statusText}`));30    return d;31  };32  const load = async (t = tab) => {33    if (!token) return;34    setErr(null);35    try { setData(await call(t === "benchmarks" ? "benchmarks/import" : t)); try { window.sessionStorage.setItem(KEY, token); } catch { /* ignore */ } }36    catch (e) { setErr(e instanceof Error ? e.message : String(e)); setData(null); }37  };38  const act = async (label: string, path: string, body?: unknown) => {39    setBusy(label);40    try { const d = await call(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }); setLog((l) => [`${new Date().toLocaleTimeString()} ${label} → ${JSON.stringify(d).slice(0, 300)}`, ...l].slice(0, 30)); await load(); }41    catch (e) { setErr(e instanceof Error ? e.message : String(e)); }42    finally { setBusy(null); }43  };44  const rows = (k: string): J[] => (Array.isArray(data?.[k]) ? (data![k] as J[]) : []);45  const s = (v: unknown) => (v == null ? "—" : String(v));4647  return (48    <div className="space-y-8 py-8">49      <section className="rise">50        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">51          <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3"><Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span><Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span><span>Admin</span></nav>52          <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{fr ? "accès protégé — jeton serveur" : "protected — server token"}</span>53        </div>54        <span className="kicker mt-6 inline-flex">{fr ? "Administration" : "Administration"}</span>55        <h1 className="vp-display mt-2 text-[clamp(24px,3.6vw,38px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? "Base de coûts — connecteurs, anomalies, mappings" : "Cost database — connectors, anomalies, mappings"}</h1>56        <div className="mt-5 flex flex-wrap items-end gap-3">57          <label className="block flex-1 min-w-[240px] max-w-md"><span className="klabel">{fr ? "Jeton d'administration (COST_ADMIN_TOKEN)" : "Admin token (COST_ADMIN_TOKEN)"}</span><input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="vp-input vp-mono text-[14px]" autoComplete="off" /></label>58          <button type="button" className="btn btn-primary" onClick={() => load()} disabled={!ready || !token}>{fr ? "Connecter" : "Connect"}</button>59        </div>60        {err && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}61      </section>6263      {data && (64        <>65          <div className="flex flex-wrap gap-1 border-b border-[var(--line)]">66            {(["connectors", "anomalies", "mappings", "benchmarks", "usage"] as const).map((t) => (<button key={t} type="button" aria-pressed={tab === t} onClick={() => { setTab(t); void load(t); }} className="vp-tab">{{ connectors: fr ? "Connecteurs" : "Connectors", anomalies: fr ? "Anomalies" : "Anomalies", mappings: fr ? "Mappings IA" : "AI mappings", benchmarks: fr ? "Benchmarks" : "Benchmarks", usage: fr ? "Usage IA" : "AI usage" }[t]}</button>))}67          </div>6869          {tab === "connectors" && (70            <section className="space-y-6">71              <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "État" : "Status"}</th><th>{fr ? "Licence" : "Licence"}</th><th>{fr ? "Dernier crawl" : "Last crawl"}</th><th className="text-right">Obs.</th><th className="text-right">{fr ? "Erreurs" : "Errors"}</th><th /></tr></thead><tbody>72                {rows("connectors").map((c, i) => (73                  <tr key={i}><td className="font-semibold">{s(c.name)}<br /><span className="vp-mono text-[10px] text-ink-3">{s(c.key)} · {s(c.frequency)}</span></td>74                    <td><span className={`pill ${c.status === "healthy" ? "pill-conf-A" : c.status === "warning" ? "pill-conf-C" : c.status === "error" ? "pill-conf-D" : "pill-conf-B"}`}>{s(c.status)}</span></td>75                    <td className="vp-mono text-[11px]">{s(c.license)}{c.active === 0 || c.active === false ? ` · ${fr ? "inactif" : "inactive"}` : ""}</td>76                    <td className="vp-mono text-[11px]">{c.lastSync ? fmtDate(String(c.lastSync), fr) : "—"}{c.lastError ? <><br /><span className="text-[var(--danger)]">{String(c.lastError).slice(0, 80)}</span></> : null}</td>77                    <td className="vp-mono text-right">{s(c.observations)}</td>78                    <td className="vp-mono text-right">{s(c.errors ?? 0)}</td>79                    <td><button type="button" disabled={busy != null} onClick={() => act(`sync ${s(c.key)}`, `sync/${encodeURIComponent(s(c.key))}`)} className="btn btn-ghost !min-h-0 !px-2.5 !py-1 text-[11px]">{busy === `sync ${s(c.key)}` ? "…" : (fr ? "Synchroniser" : "Sync")}</button></td></tr>80                ))}81              </tbody></table></div>82              {rows("runs").length > 0 && <div><p className="klabel mb-2">{fr ? "Dernières exécutions" : "Recent runs"}</p><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Connecteur" : "Connector"}</th><th>{fr ? "Début" : "Start"}</th><th>{fr ? "État" : "Status"}</th><th className="text-right">Pages</th><th className="text-right">Obs.</th><th className="text-right">{fr ? "Acceptées" : "Accepted"}</th><th className="text-right">{fr ? "Rejetées" : "Rejected"}</th><th className="text-right">{fr ? "Inchangées" : "Unchanged"}</th><th className="text-right">{fr ? "Erreurs" : "Errors"}</th></tr></thead><tbody>83                {rows("runs").map((r, i) => (<tr key={i}><td className="font-semibold">{s(r.connector)}</td><td className="vp-mono text-[11px]">{s(r.started_at ?? r.startedAt)}</td><td className="vp-mono text-[11px]">{s(r.status)}</td><td className="vp-mono text-right">{s(r.pages)}</td><td className="vp-mono text-right">{s(r.observations)}</td><td className="vp-mono text-right">{s(r.accepted)}</td><td className="vp-mono text-right">{s(r.rejected)}</td><td className="vp-mono text-right">{s(r.unchanged)}</td><td className="vp-mono text-right">{s(r.errors)}</td></tr>))}84              </tbody></table></div></div>}85            </section>86          )}8788          {tab === "anomalies" && (89            <section><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Article" : "Item"}</th><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Prix" : "Price"}</th><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Motif" : "Reason"}</th><th>URL</th></tr></thead><tbody>90              {rows("anomalies").map((a, i) => (<tr key={i}><td className="font-semibold">{s(a.item ?? a.itemCode ?? a.canonical_code)}</td><td>{s(a.source)}</td><td className="vp-mono text-right">{s(a.price ?? a.total_cost)}</td><td className="vp-mono text-[11px]">{s(a.date ?? a.observation_date)}</td><td className="text-[12px]">{s(a.reason ?? a.outlier_reason ?? a.reject_reason)}</td><td className="text-[11px]">{a.url ? <a href={String(a.url)} target="_blank" rel="noopener noreferrer nofollow" className="text-accent">↗</a> : "—"}</td></tr>))}91              {!rows("anomalies").length && <tr><td colSpan={6} className="text-ink-2">{fr ? "Aucune anomalie." : "No anomaly."}</td></tr>}92            </tbody></table></div></section>93          )}9495          {tab === "mappings" && (96            <section><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Produit" : "Product"}</th><th>{fr ? "Article proposé" : "Proposed item"}</th><th>{fr ? "Méthode" : "Method"}</th><th className="text-right">{fr ? "Confiance" : "Confidence"}</th><th>{fr ? "État" : "Status"}</th><th /></tr></thead><tbody>97              {rows("mappings").map((m, i) => (<tr key={i}><td><a href={String(m.product_url ?? m.url ?? "#")} target="_blank" rel="noopener noreferrer nofollow" className="font-semibold hover:text-accent-deep">{s(m.product_title ?? m.title)} ↗</a><br /><span className="vp-mono text-[10px] text-ink-3">{s(m.source)} · {fr ? "emballage" : "pack"} {s(m.pack_qty ?? m.packQty)} {s(m.pack_unit ?? m.packUnit)}</span></td><td className="vp-mono text-[12px]">{s(m.proposed_item_code ?? m.itemCode)}</td><td className="text-[12px]">{s(m.method)}<br /><span className="text-[10.5px] text-ink-3">{String(m.rationale ?? "").slice(0, 120)}</span></td><td className="vp-mono text-right">{Math.round(Number(m.confidence ?? 0) * (Number(m.confidence) <= 1 ? 100 : 1))} %</td><td className="vp-mono text-[11px]">{s(m.status)}</td>98                <td className="whitespace-nowrap"><button type="button" disabled={busy != null} onClick={() => act(`approve ${s(m.id)}`, `mappings/${s(m.id)}/approve`)} className="btn btn-primary !min-h-0 !px-2.5 !py-1 text-[11px]">{fr ? "Approuver" : "Approve"}</button> <button type="button" disabled={busy != null} onClick={() => act(`reject ${s(m.id)}`, `mappings/${s(m.id)}/reject`)} className="btn btn-ghost !min-h-0 !px-2.5 !py-1 text-[11px]">{fr ? "Rejeter" : "Reject"}</button></td></tr>))}99              {!rows("mappings").length && <tr><td colSpan={6} className="text-ink-2">{fr ? "Aucun mapping en attente." : "No pending mapping."}</td></tr>}100            </tbody></table></div></section>101          )}102103          {tab === "benchmarks" && (104            <section className="grid gap-x-12 gap-y-6 lg:grid-cols-2">105              <div>106                <p className="klabel">{fr ? "Import CSV (téléchargement légal requis — ex. Altus Canadian Cost Guide)" : "CSV import (legal download required — e.g. Altus Canadian Cost Guide)"}</p>107                <p className="mt-1 text-[12.5px] text-ink-2">source,building_type,market,unit,low,high,year,notes</p>108                <textarea value={csv} onChange={(e) => setCsv(e.target.value)} rows={8} className="vp-input vp-mono mt-2 w-full text-[12px]" placeholder={"Altus Group — Canadian Cost Guide,single_family,Montréal,$/pi2,260,395,2026,Custom-built"} />109                <button type="button" disabled={busy != null || !csv.trim()} onClick={() => act("import benchmarks", "benchmarks/import", { csv })} className="btn btn-primary mt-2">{fr ? "Importer" : "Import"}</button>110              </div>111              <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "Type" : "Type"}</th><th>{fr ? "Marché" : "Market"}</th><th className="text-right">{fr ? "Plage" : "Range"}</th><th>{fr ? "Année" : "Year"}</th></tr></thead><tbody>112                {rows("benchmarks").map((b, i) => (<tr key={i}><td className="font-semibold">{s(b.source)}</td><td>{s(b.building_type ?? b.buildingType)}</td><td>{s(b.market)}</td><td className="vp-mono text-right">{s(b.low)}–{s(b.high)} {s(b.unit)}</td><td className="vp-mono">{s(b.year)}</td></tr>))}113                {!rows("benchmarks").length && <tr><td colSpan={5} className="text-ink-2">{fr ? "Aucun benchmark importé." : "No benchmark imported."}</td></tr>}114              </tbody></table></div>115            </section>116          )}117118          {tab === "usage" && (119            <section>120              <div className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-4">121                {[["today", fr ? "Coût IA aujourd'hui" : "AI cost today"], ["month", fr ? "Coût IA ce mois" : "AI cost this month"], ["average", fr ? "Coût moyen / analyse" : "Average cost / analysis"], ["analyses", fr ? "Analyses" : "Analyses"]].map(([k, l]) => (<div key={k} className="kv-cell"><p className="k">{l}</p><p className="v">{data[k] != null ? (typeof data[k] === "number" && k !== "analyses" ? `${Number(data[k]).toFixed(2)} $ US` : s(data[k])) : "—"}</p></div>))}122              </div>123              <div className="src-wrap mt-4"><table className="src-table"><thead><tr><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Usage" : "Purpose"}</th><th>{fr ? "Modèle" : "Model"}</th><th className="text-right">Images</th><th className="text-right">In</th><th className="text-right">Out</th><th className="text-right">$ US</th><th className="text-right">ms</th></tr></thead><tbody>124                {rows("usage").map((u, i) => (<tr key={i}><td className="vp-mono text-[11px]">{s(u.created_at ?? u.createdAt)}</td><td>{s(u.purpose)}</td><td className="vp-mono text-[11px]">{s(u.model)}</td><td className="vp-mono text-right">{s(u.input_images)}</td><td className="vp-mono text-right">{s(u.input_tokens)}</td><td className="vp-mono text-right">{s(u.output_tokens)}</td><td className="vp-mono text-right">{u.estimated_cost_usd != null ? Number(u.estimated_cost_usd).toFixed(4) : "—"}</td><td className="vp-mono text-right">{s(u.latency_ms)}</td></tr>))}125              </tbody></table></div>126            </section>127          )}128129          {log.length > 0 && <pre className="vp-mono max-h-[200px] overflow-auto border border-[var(--line)] bg-surface p-3 text-[10.5px] leading-snug">{log.join("\n")}</pre>}130        </>131      )}132    </div>133  );134}135