SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
18.7 KB · 479 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Admin.tsx : back-office (/admin/sources, /admin/connectors,5//   /admin/brokerages) — reachable by URL only, not linked in the nav.6//   Optional token (X-Admin-Token) stored in localStorage (homeka_admin_token).7// -----------------------------------------------------------------------------8import { useCallback, useEffect, useState } from "react";9import { Link } from "react-router-dom";10import {11  AdminOverview, AdminSource, Brokerage, BrokerageFilters, ConnectorFamily,12  CustomConnector, PARTNERSHIP_STATUSES,13  fetchAdminBrokerages, fetchAdminConnectors, fetchAdminOverview,14  fetchAdminSources, getAdminToken, inspectBrokerage, runDiscovery,15  setAdminToken, setBrokerageStatus, sourceName,16} from "../api";17import { Ico } from "../components/Icons";1819const BROK_PAGE = 50;2021const n = (x: number | null | undefined) =>22  x == null ? "—" : Math.round(x).toLocaleString("en-US");2324function ago(ts: number | null | undefined): string {25  if (ts == null) return "never";26  const s = Math.max(0, Math.floor(Date.now() / 1000 - (ts > 1e12 ? ts / 1000 : ts)));27  if (s < 3600) return `${Math.round(s / 60)} min ago`;28  if (s < 172800) return `${Math.round(s / 3600)} h ago`;29  return `${Math.round(s / 86400)} d ago`;30}3132// --- Token bar -----------------------------------------------------------------33function TokenBar({ onChange }: { onChange: () => void }) {34  const [token, setToken] = useState(getAdminToken());35  const [saved, setSaved] = useState(false);36  return (37    <div className="adm-token">38      <label htmlFor="adm-token-input">Admin token</label>39      <input40        id="adm-token-input"41        type="password"42        placeholder="X-Admin-Token (leave empty if the API is open)"43        value={token}44        onChange={(e) => { setToken(e.target.value); setSaved(false); }}45        autoComplete="off"46      />47      <button48        className="btn btn-ghost"49        onClick={() => { setAdminToken(token.trim()); setSaved(true); onChange(); }}50      >51        {saved ? "Saved ✓" : "Save"}52      </button>53    </div>54  );55}5657// --- Overview cards --------------------------------------------------------------58function Overview({ ov, error }: { ov: AdminOverview | null; error: string | null }) {59  if (error) return <div className="notice">Overview unavailable — {error}</div>;60  if (!ov) return <div className="notice">Loading overview…</div>;61  return (62    <section className="tiles adm-tiles" aria-label="Overview">63      <div className="tile">64        <div className="tile-v">{n(ov.active_listings)}</div>65        <div className="tile-k">Active listings</div>66      </div>67      <div className="tile">68        <div className="tile-v">{n(ov.properties)}</div>69        <div className="tile-k">Properties</div>70      </div>71      <div className="tile">72        <div className="tile-v">{n(ov.enabled_sources)}</div>73        <div className="tile-k">Enabled sources</div>74      </div>75      <div className="tile">76        <div className="tile-v">{n(ov.errors_24h)}</div>77        <div className="tile-k">Errors (24 h)</div>78      </div>79      <div className="tile">80        <div className="tile-v">{ago(ov.last_successful_sync)}</div>81        <div className="tile-k">Last successful sync</div>82      </div>83      <div className="tile adm-tile-wide">84        <div className="tile-k" style={{ marginBottom: 6 }}>Brokerages by partnership status</div>85        <div className="adm-status-row">86          {ov.brokerages_by_status.length === 0 && <span>—</span>}87          {ov.brokerages_by_status.map((b) => (88            <span key={b.partnership_status} className="adm-badge">89              {b.partnership_status || "unknown"} <b>{n(b.n)}</b>90            </span>91          ))}92        </div>93      </div>94    </section>95  );96}9798// --- Sources tab -------------------------------------------------------------------99function SourcesTab({ reloadKey }: { reloadKey: number }) {100  const [rows, setRows] = useState<AdminSource[] | null>(null);101  const [error, setError] = useState<string | null>(null);102103  useEffect(() => {104    setRows(null); setError(null);105    fetchAdminSources().then((r) => setRows(r.sources)).catch((e) => setError(String(e)));106  }, [reloadKey]);107108  if (error) return <div className="notice">Could not load sources — {error}</div>;109  if (!rows) return <div className="notice">Loading sources…</div>;110  return (111    <div className="q-table-wrap">112      <table className="q-table adm-table">113        <thead>114          <tr>115            <th>Source</th><th>Type</th><th>States</th>116            <th>Active</th><th>Published</th><th>Last sync</th>117            <th>Freshness</th><th>Errors 7d</th><th>Last message</th>118          </tr>119        </thead>120        <tbody>121          {rows.map((s) => (122            <tr key={s.id}>123              <td>124                <b>{s.name || s.id}</b>125                {!s.enabled && <span className="adm-badge adm-off"> disabled</span>}126                <div className="adm-sub">{s.id}</div>127              </td>128              <td>{s.connector_type ?? "—"}</td>129              <td>{s.states?.length ? s.states.join(", ") : "—"}</td>130              <td className="num">{n(s.active_listings)}</td>131              <td className="num">{n(s.published)}</td>132              <td>133                {s.last_sync != null ? ago(s.last_sync) : "never"}{" "}134                {s.last_sync != null && (135                  s.last_ok136                    ? <span className="adm-badge adm-ok">OK</span>137                    : <span className="adm-badge adm-err">Error</span>138                )}139              </td>140              <td className="num">{s.freshness_hours != null ? `${s.freshness_hours} h` : "—"}</td>141              <td className="num">{n(s.errors_7d)}</td>142              <td className="adm-msg" title={s.last_message ?? ""}>{s.last_message ?? "—"}</td>143            </tr>144          ))}145        </tbody>146      </table>147    </div>148  );149}150151// --- Connectors tab ------------------------------------------------------------------152function ConnectorsTab({ reloadKey }: { reloadKey: number }) {153  const [families, setFamilies] = useState<ConnectorFamily[] | null>(null);154  const [custom, setCustom] = useState<CustomConnector[]>([]);155  const [error, setError] = useState<string | null>(null);156157  useEffect(() => {158    setFamilies(null); setError(null);159    fetchAdminConnectors()160      .then((r) => { setFamilies(r.families); setCustom(r.custom); })161      .catch((e) => setError(String(e)));162  }, [reloadKey]);163164  if (error) return <div className="notice">Could not load connectors — {error}</div>;165  if (!families) return <div className="notice">Loading connectors…</div>;166  return (167    <>168      <div className="adm-cards">169        {families.map((f) => (170          <div className="adm-card" key={f.family}>171            <div className="adm-card-head">172              <b>{f.family}</b>173              <span className="adm-badge">{n(f.instances)} instance{f.instances !== 1 ? "s" : ""}</span>174            </div>175            <div className="adm-sub">{f.class} · {f.module}</div>176            <p>{f.doc || "—"}</p>177          </div>178        ))}179      </div>180      {custom.length > 0 && (181        <>182          <h2 style={{ marginTop: 22 }}>Custom connectors</h2>183          <div className="q-table-wrap">184            <table className="q-table">185              <thead><tr><th>Source</th><th>Class</th><th>Module</th></tr></thead>186              <tbody>187                {custom.map((c) => (188                  <tr key={c.source_id}>189                    <td>{sourceName(c.source_id)} <span className="adm-sub">{c.source_id}</span></td>190                    <td>{c.class}</td>191                    <td>{c.module}</td>192                  </tr>193                ))}194              </tbody>195            </table>196          </div>197        </>198      )}199    </>200  );201}202203// --- Brokerages tab ---------------------------------------------------------------------204function BrokeragesTab({ reloadKey }: { reloadKey: number }) {205  const [rows, setRows] = useState<Brokerage[] | null>(null);206  const [total, setTotal] = useState(0);207  const [page, setPage] = useState(1);208  const [error, setError] = useState<string | null>(null);209  const [busy, setBusy] = useState<Record<number, boolean>>({});210  const [discovering, setDiscovering] = useState(false);211212  // filters213  const [q, setQ] = useState("");214  const [state, setState] = useState("");215  const [status, setStatus] = useState("");216  const [feedType, setFeedType] = useState("");217  const [minPriority, setMinPriority] = useState("");218  const [inspected, setInspected] = useState("");219  const [sort, setSort] = useState("priority");220221  const filters: BrokerageFilters = {222    q, state, status, feed_type: feedType,223    min_priority: minPriority, inspected, sort,224  };225  const filterKey = JSON.stringify(filters);226227  const load = useCallback((p: number) => {228    setRows(null); setError(null);229    fetchAdminBrokerages(JSON.parse(filterKey) as BrokerageFilters, BROK_PAGE, (p - 1) * BROK_PAGE)230      .then((r) => { setRows(r.brokerages); setTotal(r.total); })231      .catch((e) => setError(String(e)));232  }, [filterKey]);233234  useEffect(() => { setPage(1); }, [filterKey]);235  useEffect(() => { load(page); }, [load, page, reloadKey]);236237  const totalPages = Math.max(1, Math.ceil(total / BROK_PAGE));238239  const onStatus = async (b: Brokerage, next: string) => {240    setBusy((x) => ({ ...x, [b.id]: true }));241    try {242      await setBrokerageStatus(b.id, next);243      setRows((rs) => rs?.map((r) => r.id === b.id ? { ...r, partnership_status: next } : r) ?? null);244    } catch (e) {245      alert(`Status update failed: ${e}`);246    } finally {247      setBusy((x) => ({ ...x, [b.id]: false }));248    }249  };250251  const onInspect = async (b: Brokerage) => {252    setBusy((x) => ({ ...x, [b.id]: true }));253    try {254      await inspectBrokerage(b.id);255      // refresh the page to pick up the newly inspected row256      load(page);257    } catch (e) {258      alert(`Inspection failed: ${e}`);259      setBusy((x) => ({ ...x, [b.id]: false }));260    }261  };262263  const onDiscover = async () => {264    setDiscovering(true);265    try {266      await runDiscovery();267      alert("Discovery batch started in the background.");268    } catch (e) {269      alert(`Discovery failed to start: ${e}`);270    } finally {271      setDiscovering(false);272    }273  };274275  const contacts = (b: Brokerage): { label: string; email: string }[] => {276    const out: { label: string; email: string }[] = [];277    if (b.partnership_contact && b.partnership_contact.includes("@"))278      out.push({ label: "partnership", email: b.partnership_contact });279    if (b.technical_contact && b.technical_contact.includes("@"))280      out.push({ label: "technical", email: b.technical_contact });281    return out;282  };283284  return (285    <>286      <div className="adm-controls">287        <input288          placeholder="Search name or website…"289          value={q}290          onChange={(e) => setQ(e.target.value)}291          aria-label="Search brokerages"292        />293        <input294          placeholder="State (e.g. TX)"295          value={state}296          maxLength={2}297          style={{ width: 90 }}298          onChange={(e) => setState(e.target.value.toUpperCase())}299          aria-label="State"300        />301        <select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Partnership status">302          <option value="">Any status</option>303          {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}304        </select>305        <select value={feedType} onChange={(e) => setFeedType(e.target.value)} aria-label="Feed type">306          <option value="">Any feed</option>307          {["reso", "rets", "json-api", "xml", "csv", "idx-partner", "crawl"].map((f) => (308            <option key={f} value={f}>{f}</option>309          ))}310        </select>311        <input312          type="number"313          placeholder="Min priority"314          value={minPriority}315          style={{ width: 110 }}316          onChange={(e) => setMinPriority(e.target.value)}317          aria-label="Minimum priority score"318        />319        <select value={inspected} onChange={(e) => setInspected(e.target.value)} aria-label="Inspected">320          <option value="">Inspected or not</option>321          <option value="1">Inspected</option>322          <option value="0">Not inspected</option>323        </select>324        <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">325          <option value="priority">Sort: priority</option>326          <option value="feed">Sort: feed probability</option>327          <option value="name">Sort: name</option>328          <option value="inspected">Sort: last inspected</option>329        </select>330        <button className="btn btn-primary" onClick={onDiscover} disabled={discovering}>331          {discovering ? "Starting…" : "Run discovery batch"}332        </button>333      </div>334335      {error && <div className="notice">Could not load brokerages — {error}</div>}336      {!rows && !error && <div className="notice">Loading brokerages…</div>}337338      {rows && (339        <>340          <p className="stats-foot" style={{ marginTop: 0 }}>341            <b>{n(total)}</b> brokerage{total !== 1 ? "s" : ""} — page {page} / {totalPages}342          </p>343          <div className="q-table-wrap">344            <table className="q-table adm-table">345              <thead>346                <tr>347                  <th>Brokerage</th><th>States</th><th>Agents</th><th>Listings</th>348                  <th>IDX provider</th><th>Feed</th><th>Feed %</th><th>Priority</th>349                  <th>Status</th><th>Contacts</th><th>Inspected</th><th></th>350                </tr>351              </thead>352              <tbody>353                {rows.map((b) => (354                  <tr key={b.id}>355                    <td>356                      {b.website357                        ? <a href={b.website} target="_blank" rel="noopener noreferrer"><b>{b.name}</b> <Ico name="external" size={11} /></a>358                        : <b>{b.name}</b>}359                      {b.mls_affiliations?.length > 0 && (360                        <div className="adm-sub">{b.mls_affiliations.join(", ")}</div>361                      )}362                    </td>363                    <td>{b.states?.length ? b.states.join(", ") : "—"}</td>364                    <td className="num">{n(b.estimated_agents)}</td>365                    <td className="num">{n(b.estimated_listings)}</td>366                    <td>367                      {b.idx_provider ?? "—"}368                      {b.reso_detected ? <span className="adm-badge adm-ok"> RESO</span> : null}369                    </td>370                    <td>{b.possible_feed_type ?? "—"}</td>371                    <td className="num">{b.feed_probability_score != null ? Math.round(b.feed_probability_score) : "—"}</td>372                    <td className="num">{b.priority_score != null ? Math.round(b.priority_score) : "—"}</td>373                    <td>374                      <select375                        className="adm-status-select"376                        value={b.partnership_status || "prospect"}377                        disabled={!!busy[b.id]}378                        onChange={(e) => onStatus(b, e.target.value)}379                        aria-label={`Partnership status — ${b.name}`}380                      >381                        {PARTNERSHIP_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}382                      </select>383                    </td>384                    <td>385                      {contacts(b).length === 0 && "—"}386                      {contacts(b).map((c) => (387                        <div key={c.email}>388                          <a href={`mailto:${c.email}`} title={c.label}>{c.email}</a>389                        </div>390                      ))}391                    </td>392                    <td>393                      {b.last_inspected != null ? ago(b.last_inspected) : "never"}394                      {b.inspect_error && (395                        <span className="adm-badge adm-err" title={b.inspect_error}> err</span>396                      )}397                    </td>398                    <td>399                      <button400                        className="btn btn-ghost adm-inspect"401                        disabled={!!busy[b.id]}402                        onClick={() => onInspect(b)}403                      >404                        {busy[b.id] ? "…" : "Inspect"}405                      </button>406                    </td>407                  </tr>408                ))}409                {rows.length === 0 && (410                  <tr><td colSpan={12}>No brokerage matches these filters.</td></tr>411                )}412              </tbody>413            </table>414          </div>415          {totalPages > 1 && (416            <nav className="pager" aria-label="Brokerage pagination">417              <button className="pager-btn" onClick={() => setPage(page - 1)} disabled={page <= 1}>‹ Prev</button>418              <span className="pager-info">Page {page} / {totalPages}</span>419              <button className="pager-btn" onClick={() => setPage(page + 1)} disabled={page >= totalPages}>Next ›</button>420            </nav>421          )}422        </>423      )}424    </>425  );426}427428// --- Page ------------------------------------------------------------------------429export type AdminTab = "sources" | "connectors" | "brokerages";430431export default function AdminPage({ tab }: { tab: AdminTab }) {432  const [ov, setOv] = useState<AdminOverview | null>(null);433  const [ovError, setOvError] = useState<string | null>(null);434  const [reloadKey, setReloadKey] = useState(0);435436  const loadOverview = useCallback(() => {437    setOv(null); setOvError(null);438    fetchAdminOverview().then(setOv).catch((e) => setOvError(String(e)));439  }, []);440  useEffect(() => { loadOverview(); }, [loadOverview, reloadKey]);441442  const tabs: { id: AdminTab; label: string }[] = [443    { id: "sources", label: "Sources" },444    { id: "connectors", label: "Connectors" },445    { id: "brokerages", label: "Brokerages" },446  ];447448  return (449    <div className="container admin">450      <span className="kicker">Back office</span>451      <h1>Home-Ka admin</h1>452      <p className="sub">453        Operational view of the aggregation pipeline — sources, connector454        families and the brokerage partnership funnel.455      </p>456457      <TokenBar onChange={() => setReloadKey((k) => k + 1)} />458459      <Overview ov={ov} error={ovError} />460461      <nav className="adm-tabs" aria-label="Admin sections">462        {tabs.map((t) => (463          <Link464            key={t.id}465            to={`/admin/${t.id}`}466            className={tab === t.id ? "on" : ""}467          >468            {t.label}469          </Link>470        ))}471      </nav>472473      {tab === "sources" && <SourcesTab reloadKey={reloadKey} />}474      {tab === "connectors" && <ConnectorsTab reloadKey={reloadKey} />}475      {tab === "brokerages" && <BrokeragesTab reloadKey={reloadKey} />}476    </div>477  );478}479