| 1 | "use client"; |
| 2 | import { Suspense, useEffect, useState } from "react"; |
| 3 | import { useSearchParams } from "next/navigation"; |
| 4 | import { api, MovementSummary, PlateProfile } from "@/lib/api"; |
| 5 | |
| 6 | function SearchInner() { |
| 7 | const params = useSearchParams(); |
| 8 | const [q, setQ] = useState(params.get("q") || ""); |
| 9 | const [partial, setPartial] = useState(false); |
| 10 | const [results, setResults] = useState<{ plate_norm: string; state: string }[]>([]); |
| 11 | const [profile, setProfile] = useState<PlateProfile | null>(null); |
| 12 | const [movement, setMovement] = useState<MovementSummary | null>(null); |
| 13 | const [err, setErr] = useState<string | null>(null); |
| 14 | |
| 15 | async function run(plate?: string) { |
| 16 | setErr(null); setProfile(null); setMovement(null); |
| 17 | const query = plate || q; |
| 18 | if (!query) return; |
| 19 | try { |
| 20 | if (plate) { |
| 21 | const [p, m] = await Promise.all([api.plateProfile(plate), api.movement(plate)]); |
| 22 | setProfile(p); setMovement(m); setResults([]); |
| 23 | } else { |
| 24 | setResults(await api.plateSearch(query, partial)); |
| 25 | } |
| 26 | } catch (e: any) { |
| 27 | setErr(e.status === 401 ? "Sign in to search." : "No results."); |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | useEffect(() => { if (params.get("q")) run(params.get("q")!); }, []); |
| 32 | |
| 33 | return ( |
| 34 | <div> |
| 35 | <h1>Plate Search</h1> |
| 36 | <div className="row" style={{ margin: "12px 0" }}> |
| 37 | <input value={q} onChange={(e) => setQ(e.target.value.toUpperCase())} placeholder="ABC1234" /> |
| 38 | <label className="row"><input type="checkbox" checked={partial} onChange={(e) => setPartial(e.target.checked)} style={{ width: 16 }} /> partial</label> |
| 39 | <button className="primary" onClick={() => run()}>Search</button> |
| 40 | </div> |
| 41 | {err && <div className="badge danger">{err}</div>} |
| 42 | |
| 43 | {results.length > 0 && ( |
| 44 | <div className="card"> |
| 45 | <table> |
| 46 | <thead><tr><th>Plate</th><th>State</th></tr></thead> |
| 47 | <tbody>{results.map((r) => ( |
| 48 | <tr key={r.plate_norm}><td><a onClick={() => run(r.plate_norm)} style={{ cursor: "pointer" }}>{r.plate_norm}</a></td><td>{r.state}</td></tr> |
| 49 | ))}</tbody> |
| 50 | </table> |
| 51 | </div> |
| 52 | )} |
| 53 | |
| 54 | {profile && ( |
| 55 | <> |
| 56 | <h2>{profile.plate_norm} <a href={`/vehicles/${profile.plate_norm}`} style={{ fontSize: 13 }}>full vehicle profile →</a></h2> |
| 57 | <div className="grid"> |
| 58 | <div className="card"><div className="muted">Total reads</div><div className="kpi">{profile.total_reads}</div></div> |
| 59 | <div className="card"><div className="muted">Distinct cameras</div><div className="kpi">{profile.distinct_cameras}</div></div> |
| 60 | <div className="card"><div className="muted">First seen</div><div>{fmt(profile.first_seen)}</div></div> |
| 61 | <div className="card"><div className="muted">Last seen</div><div>{fmt(profile.last_seen)}</div></div> |
| 62 | </div> |
| 63 | </> |
| 64 | )} |
| 65 | |
| 66 | {movement && movement.anomalies.length > 0 && ( |
| 67 | <> |
| 68 | <h2>⚠ Movement anomalies</h2> |
| 69 | <div className="card" style={{ borderColor: "var(--danger)" }}> |
| 70 | {movement.anomalies.map((a, i) => ( |
| 71 | <div key={i} className="row" style={{ justifyContent: "space-between" }}> |
| 72 | <span>{a.reason.replace(/_/g, " ")}</span> |
| 73 | <span className="badge danger">{Math.round(a.speed_kmh)} km/h</span> |
| 74 | </div> |
| 75 | ))} |
| 76 | <p className="muted" style={{ marginBottom: 0 }}>Implausible transit speed suggests a misread or cloned plate.</p> |
| 77 | </div> |
| 78 | </> |
| 79 | )} |
| 80 | |
| 81 | {movement && ( |
| 82 | <> |
| 83 | <h2>Route legs</h2> |
| 84 | <div className="card"> |
| 85 | <table> |
| 86 | <thead><tr><th>From</th><th>To</th><th>Elapsed</th><th>Distance</th><th>Speed</th></tr></thead> |
| 87 | <tbody>{movement.route.map((l, i) => ( |
| 88 | <tr key={i}> |
| 89 | <td className="muted">{l.from?.slice(0, 8)}</td> |
| 90 | <td className="muted">{l.to?.slice(0, 8)}</td> |
| 91 | <td>{Math.round(l.elapsed_s)}s</td> |
| 92 | <td>{l.distance_km ? l.distance_km.toFixed(1) + " km" : "-"}</td> |
| 93 | <td>{l.speed_kmh ? <span className={"badge " + (l.implausible ? "danger" : "ok")}>{Math.round(l.speed_kmh)} km/h</span> : "-"}</td> |
| 94 | </tr> |
| 95 | ))}</tbody> |
| 96 | </table> |
| 97 | </div> |
| 98 | </> |
| 99 | )} |
| 100 | </div> |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | function fmt(s: string | null) { return s ? new Date(s).toLocaleString() : "-"; } |
| 105 | |
| 106 | export default function Search() { |
| 107 | return <Suspense fallback={<div className="muted">Loading…</div>}><SearchInner /></Suspense>; |
| 108 | } |