● Zion Boggan
repos/Sightline/frontend/src/app/import/page.tsx
zionboggan.com ↗
104 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useRef, useState } from "react";
3
import { api, ImportResult, ImportRow, Source } from "@/lib/api";
4
 
5
export default function ImportPage() {
6
  const [sources, setSources] = useState<Source[]>([]);
7
  const [sourceId, setSourceId] = useState<string>("");
8
  const [imports, setImports] = useState<ImportRow[]>([]);
9
  const [result, setResult] = useState<ImportResult | null>(null);
10
  const [err, setErr] = useState<string | null>(null);
11
  const [dragging, setDragging] = useState(false);
12
  const [busy, setBusy] = useState(false);
13
  const fileRef = useRef<HTMLInputElement>(null);
14
 
15
  function load() {
16
    api.imports().then(setImports).catch((e) =>
17
      setErr(e.status === 401 ? "Sign in to import data." : String(e)));
18
    api.sources().then(setSources).catch(() => {});
19
  }
20
  useEffect(load, []);
21
 
22
  async function upload(file: File) {
23
    setErr(null); setResult(null); setBusy(true);
24
    try {
25
      setResult(await api.uploadImport(file, sourceId || undefined));
26
      load();
27
    } catch (e: any) {
28
      setErr(e.status === 401 ? "Sign in (analyst role) to import." :
29
             e.status === 403 ? "Your role cannot ingest data." : "Upload failed.");
30
    } finally { setBusy(false); }
31
  }
32
 
33
  async function newSource() {
34
    const name = prompt("Source name (e.g. 'Downtown Flock Network')");
35
    if (!name) return;
36
    try { const s = await api.createSource(name); setSources((p) => [...p, s]); setSourceId(s.id); }
37
    catch { setErr("Could not create source (needs ingest permission)."); }
38
  }
39
 
40
  return (
41
    <div>
42
      <h1>ALPR Import</h1>
43
      <p className="muted">Upload Flock-style CSV or JSON exports. Columns are auto-mapped;
44
        rows are normalized, deduplicated, and evaluated against active watchlists on ingest.</p>
45
 
46
      <div className="row" style={{ margin: "12px 0" }}>
47
        <select value={sourceId} onChange={(e) => setSourceId(e.target.value)}>
48
          <option value="">(no source)</option>
49
          {sources.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
50
        </select>
51
        <button onClick={newSource}>+ New source</button>
52
      </div>
53
 
54
      <div
55
        className="card"
56
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
57
        onDragLeave={() => setDragging(false)}
58
        onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]); }}
59
        style={{ textAlign: "center", padding: 40, borderStyle: "dashed",
60
                 borderColor: dragging ? "var(--accent)" : "var(--border)", cursor: "pointer" }}
61
        onClick={() => fileRef.current?.click()}
62
      >
63
        <input ref={fileRef} type="file" accept=".csv,.json" hidden
64
               onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
65
        <div style={{ fontSize: 16 }}>{busy ? "Uploading…" : "Drop a .csv / .json export here, or click to browse"}</div>
66
        <div className="muted" style={{ marginTop: 6 }}>Re-uploading an identical file is deduplicated automatically.</div>
67
      </div>
68
 
69
      {err && <div className="badge danger" style={{ marginTop: 12 }}>{err}</div>}
70
 
71
      {result && (
72
        <div className="card" style={{ marginTop: 12, borderColor: "var(--ok)" }}>
73
          {result.deduplicated
74
            ? <div>Already imported - <strong>{result.row_count ?? "existing"}</strong> rows (no double-count).</div>
75
            : <div className="row" style={{ gap: 18 }}>
76
                <span>inserted <strong>{result.inserted}</strong></span>
77
                <span>duplicates <strong>{result.duplicates}</strong></span>
78
                <span>skipped <strong>{result.skipped}</strong></span>
79
                <span>watchlist hits <strong className="badge danger">{result.watchlist_hits}</strong></span>
80
              </div>}
81
          {!!result.errors?.length && <div className="muted" style={{ marginTop: 8 }}>{result.errors.length} row warning(s).</div>}
82
        </div>
83
      )}
84
 
85
      <h2>Recent imports</h2>
86
      <div className="card">
87
        <table>
88
          <thead><tr><th>File</th><th>Status</th><th>Rows</th><th>When</th></tr></thead>
89
          <tbody>
90
            {imports.map((i) => (
91
              <tr key={i.id}>
92
                <td>{i.filename}</td>
93
                <td><span className={"badge " + (i.status === "done" ? "ok" : "")}>{i.status}</span></td>
94
                <td>{i.row_count}</td>
95
                <td className="muted">{new Date(i.created_at).toLocaleString()}</td>
96
              </tr>
97
            ))}
98
            {!imports.length && !err && <tr><td colSpan={4} className="muted">No imports yet.</td></tr>}
99
          </tbody>
100
        </table>
101
      </div>
102
    </div>
103
  );
104
}