Python 49.6%
TypeScript 25.5%
CSS 24.1%
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