Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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 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