| 1 | "use client"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { api, ProfileDossier, ProfileScope, ProfileSubject } from "@/lib/api"; |
| 4 | |
| 5 | export default function ProfilesPage() { |
| 6 | const [scope, setScope] = useState<ProfileScope | null>(null); |
| 7 | const [q, setQ] = useState(""); |
| 8 | const [results, setResults] = useState<ProfileSubject[]>([]); |
| 9 | const [dossier, setDossier] = useState<ProfileDossier | null>(null); |
| 10 | const [err, setErr] = useState<string | null>(null); |
| 11 | |
| 12 | const loadScope = () => api.profileScope().then(setScope).catch(() => {}); |
| 13 | useEffect(() => { loadScope(); }, []); |
| 14 | |
| 15 | async function toggleScope(mode: "consented" | "all") { |
| 16 | try { await api.setProfileScope(mode); loadScope(); setResults([]); setDossier(null); } |
| 17 | catch { setErr("Scope change needs admin (open mode = admin)."); } |
| 18 | } |
| 19 | async function search() { |
| 20 | setErr(null); setDossier(null); |
| 21 | try { setResults((await api.profileSearch(q)).results); } |
| 22 | catch (e: any) { setErr("Search failed."); } |
| 23 | } |
| 24 | async function open(id: string) { |
| 25 | setErr(null); |
| 26 | try { setDossier(await api.buildProfile(id)); } |
| 27 | catch (e: any) { setErr(e.status === 403 ? "Locked: subject is not a consented subject." : "Build failed."); } |
| 28 | } |
| 29 | async function addSubject() { |
| 30 | const name = prompt("Subject full name (consented)"); if (!name) return; |
| 31 | const plates = (prompt("Plate(s), comma-separated") || "").split(",").map((s) => s.trim()).filter(Boolean); |
| 32 | try { await api.createSubject(name, plates, true); search(); } |
| 33 | catch { setErr("Could not create subject."); } |
| 34 | } |
| 35 | |
| 36 | return ( |
| 37 | <div> |
| 38 | <div className="row" style={{ justifyContent: "space-between" }}> |
| 39 | <div> |
| 40 | <h1>Profile Engine</h1> |
| 41 | <p className="sub">Entity-resolution dossier builder - identity, vehicles, cross-state movement, detections.</p> |
| 42 | </div> |
| 43 | </div> |
| 44 | |
| 45 | {} |
| 46 | <div className="card" style={{ borderColor: scope?.mode === "consented" ? "var(--ok)" : "var(--amber)" }}> |
| 47 | <div className="row" style={{ justifyContent: "space-between" }}> |
| 48 | <div> |
| 49 | <div className="row" style={{ gap: 8 }}> |
| 50 | <strong style={{ fontFamily: "var(--mono)" }}>SCOPE</strong> |
| 51 | <span className={"badge " + (scope?.mode === "consented" ? "ok" : "amber")}> |
| 52 | {scope?.mode === "consented" ? "● CONSENTED SUBJECTS ONLY" : "● ALL INGESTED DATA"} |
| 53 | </span> |
| 54 | </div> |
| 55 | <div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{scope?.description}</div> |
| 56 | </div> |
| 57 | <div className="row"> |
| 58 | <span className={"chip " + (scope?.mode === "consented" ? "on" : "")} onClick={() => toggleScope("consented")}>Me + Brother only</span> |
| 59 | <span className={"chip " + (scope?.mode === "all" ? "on" : "")} onClick={() => toggleScope("all")}>All data</span> |
| 60 | </div> |
| 61 | </div> |
| 62 | <div className="muted" style={{ fontSize: 11, marginTop: 8 }}> |
| 63 | {scope?.consented_subjects} consented subject(s) registered. In consented mode the engine |
| 64 | will refuse to build a profile for anyone not whitelisted. |
| 65 | </div> |
| 66 | </div> |
| 67 | |
| 68 | <div className="row" style={{ margin: "14px 0" }}> |
| 69 | <input placeholder="search subject name…" value={q} onChange={(e) => setQ(e.target.value)} |
| 70 | onKeyDown={(e) => e.key === "Enter" && search()} style={{ minWidth: 260 }} /> |
| 71 | <button className="primary" onClick={search}>Search</button> |
| 72 | <button onClick={addSubject}>+ Add consented subject</button> |
| 73 | </div> |
| 74 | {err && <div className="badge danger">{err}</div>} |
| 75 | |
| 76 | {results.length > 0 && !dossier && ( |
| 77 | <div className="card"> |
| 78 | <table> |
| 79 | <thead><tr><th>Name</th><th>Plates</th><th>Status</th><th></th></tr></thead> |
| 80 | <tbody> |
| 81 | {results.map((r) => ( |
| 82 | <tr key={r.id}> |
| 83 | <td>{r.full_name}</td> |
| 84 | <td className="mono">{r.plates.join(", ") || "-"}</td> |
| 85 | <td>{r.consented ? <span className="badge ok">consented</span> : <span className="badge amber">not consented</span>}</td> |
| 86 | <td><button onClick={() => open(r.id)}>Build profile →</button></td> |
| 87 | </tr> |
| 88 | ))} |
| 89 | </tbody> |
| 90 | </table> |
| 91 | </div> |
| 92 | )} |
| 93 | |
| 94 | {dossier && ( |
| 95 | <div> |
| 96 | <div className="row" style={{ justifyContent: "space-between", marginTop: 8 }}> |
| 97 | <h2 style={{ margin: 0 }}>{dossier.subject.full_name}</h2> |
| 98 | <button onClick={() => setDossier(null)}>← Back</button> |
| 99 | </div> |
| 100 | <div className="row" style={{ margin: "8px 0" }}> |
| 101 | {dossier.subject.consented && <span className="badge ok">consented subject</span>} |
| 102 | <span className="badge mono">{dossier.subject.plates.join(", ") || "no plate"}</span> |
| 103 | </div> |
| 104 | |
| 105 | <div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill,minmax(170px,1fr))" }}> |
| 106 | <div className="kpi-card"><div className="lbl">Total reads</div><div className="val accent">{dossier.summary.total_reads}</div></div> |
| 107 | <div className="kpi-card"><div className="lbl">States visited</div><div className="val">{dossier.summary.states_visited.length}</div></div> |
| 108 | <div className="kpi-card"><div className="lbl">Cities</div><div className="val">{dossier.summary.cities_visited.length}</div></div> |
| 109 | <div className="kpi-card"><div className="lbl">Cameras</div><div className="val">{dossier.summary.distinct_cameras}</div></div> |
| 110 | </div> |
| 111 | |
| 112 | {dossier.summary.states_visited.length > 0 && ( |
| 113 | <div className="row" style={{ margin: "12px 0" }}> |
| 114 | <span className="muted">States:</span> |
| 115 | {dossier.summary.states_visited.map((s) => <span key={s} className="badge accent">{s}</span>)} |
| 116 | </div> |
| 117 | )} |
| 118 | |
| 119 | <h2>Movement timeline</h2> |
| 120 | <div className="card scroll" style={{ maxHeight: 420 }}> |
| 121 | <table> |
| 122 | <thead><tr><th>Time</th><th>Camera</th><th>State</th><th>City</th><th>Plate</th></tr></thead> |
| 123 | <tbody> |
| 124 | {dossier.timeline.map((t, i) => ( |
| 125 | <tr key={i}> |
| 126 | <td className="mono">{new Date(t.read_ts).toLocaleString()}</td> |
| 127 | <td>{t.camera || "-"}</td> |
| 128 | <td><span className="badge">{t.state || "-"}</span></td> |
| 129 | <td className="muted">{t.area || "-"}</td> |
| 130 | <td className="mono">{t.plate_norm}</td> |
| 131 | </tr> |
| 132 | ))} |
| 133 | {!dossier.timeline.length && <tr><td colSpan={5} className="muted"> |
| 134 | No sightings yet. Ingest reads for this subject's plate (run the ALPR engine on |
| 135 | your own car/footage, or import from your Flock unit) and they'll appear here. |
| 136 | </td></tr>} |
| 137 | </tbody> |
| 138 | </table> |
| 139 | </div> |
| 140 | |
| 141 | {dossier.detections.length > 0 && ( |
| 142 | <> |
| 143 | <h2>Appearance detections</h2> |
| 144 | <div className="card"> |
| 145 | <table> |
| 146 | <thead><tr><th>Time</th><th>Clothing</th><th>Articles</th></tr></thead> |
| 147 | <tbody> |
| 148 | {dossier.detections.map((d, i) => ( |
| 149 | <tr key={i}><td className="mono">{new Date(d.read_ts).toLocaleString()}</td> |
| 150 | <td>{d.clothing_top}</td><td className="muted">{(d.articles || []).join(", ")}</td></tr> |
| 151 | ))} |
| 152 | </tbody> |
| 153 | </table> |
| 154 | </div> |
| 155 | </> |
| 156 | )} |
| 157 | </div> |
| 158 | )} |
| 159 | </div> |
| 160 | ); |
| 161 | } |