Files
server-26/drb-frontend/app/systems/page.tsx
T

704 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ──────────────────────────────────────────────
interface TalkgroupEntry {
id: string;
name: string;
tag: string;
}
interface P25Config {
nac: string;
system_id: string;
wacn: string;
control_channels: string;
voice_channels: string;
talkgroups: TalkgroupEntry[];
}
const DEFAULT_P25: P25Config = {
nac: "",
system_id: "",
wacn: "",
control_channels: "",
voice_channels: "",
talkgroups: [],
};
const TG_TAGS = ["fire", "police", "ems", "transit", "public works", "other"];
function recordToP25Config(c: Record<string, unknown>): P25Config {
return {
nac: String(c.nac ?? ""),
system_id: String(c.system_id ?? ""),
wacn: String(c.wacn ?? ""),
control_channels: Array.isArray(c.control_channels)
? (c.control_channels as number[]).join(", ")
: "",
voice_channels: Array.isArray(c.voice_channels)
? (c.voice_channels as number[]).join(", ")
: "",
talkgroups: Array.isArray(c.talkgroups)
? (c.talkgroups as Array<{ id: number; name: string; tag: string }>).map((tg) => ({
id: String(tg.id),
name: tg.name,
tag: tg.tag ?? "other",
}))
: [],
};
}
function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
const parseFreqs = (s: string) =>
s
.split(",")
.map((f) => parseFloat(f.trim()))
.filter((f) => !isNaN(f));
return {
nac: p.nac,
system_id: p.system_id ? parseInt(p.system_id, 10) : undefined,
wacn: p.wacn,
control_channels: parseFreqs(p.control_channels),
voice_channels: parseFreqs(p.voice_channels),
talkgroups: p.talkgroups
.filter((tg) => tg.id && tg.name)
.map((tg) => ({ id: parseInt(tg.id, 10), name: tg.name, tag: tg.tag })),
};
}
// ── Talkgroup table editor ────────────────────────────────────────────────────
function TalkgroupEditor({
talkgroups,
onChange,
}: {
talkgroups: TalkgroupEntry[];
onChange: (tgs: TalkgroupEntry[]) => void;
}) {
const [showPaste, setShowPaste] = useState(false);
const [pasteText, setPasteText] = useState("");
function addRow() {
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
}
function removeRow(i: number) {
onChange(talkgroups.filter((_, idx) => idx !== i));
}
function updateRow(i: number, field: keyof TalkgroupEntry, value: string) {
const updated = [...talkgroups];
updated[i] = { ...updated[i], [field]: value };
onChange(updated);
}
function handlePasteImport() {
const lines = pasteText.trim().split("\n");
const parsed: TalkgroupEntry[] = lines
.map((line) => {
const parts = line.includes("\t") ? line.split("\t") : line.split(",");
const id = parts[0]?.trim() ?? "";
const name = parts[1]?.trim() ?? "";
const rawTag = parts[2]?.trim()?.toLowerCase() ?? "other";
const tag = TG_TAGS.includes(rawTag) ? rawTag : "other";
return { id, name, tag };
})
.filter((e) => e.id && e.name);
onChange([...talkgroups, ...parsed]);
setPasteText("");
setShowPaste(false);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowPaste(!showPaste)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
{showPaste ? "Cancel paste" : "Paste import"}
</button>
<button
type="button"
onClick={addRow}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add row
</button>
</div>
</div>
{showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
<p className="text-xs text-gray-500">
Paste rows from RadioReference tab- or comma-separated: <span className="text-gray-400">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other
</p>
<textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={handlePasteImport}
disabled={!pasteText.trim()}
className="bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors"
>
Import rows
</button>
</div>
)}
{talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-800 text-gray-400">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th>
<th className="px-3 py-1.5 w-8"></th>
</tr>
</thead>
<tbody>
{talkgroups.map((tg, i) => (
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800/30">
<td className="px-2 py-1">
<input
value={tg.id}
onChange={(e) => updateRow(i, "id", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="1234"
/>
</td>
<td className="px-2 py-1">
<input
value={tg.name}
onChange={(e) => updateRow(i, "name", e.target.value)}
className="w-full bg-transparent text-white focus:outline-none"
placeholder="Fire Dispatch"
/>
</td>
<td className="px-2 py-1">
<select
value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1"
>
{TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</td>
<td className="px-2 py-1 text-center">
<button
type="button"
onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-xs text-gray-600 italic py-1">No talkgroups add rows or paste from RadioReference.</p>
)}
</div>
);
}
// ── P25 structured form ───────────────────────────────────────────────────────
function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label>
<input
value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">System ID</label>
<input
value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label>
<input
value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
</label>
<input
value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated leave blank for auto-discovery)</span>
</label>
<input
value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional"
/>
</div>
<TalkgroupEditor
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
</div>
);
}
// ── Main system form ──────────────────────────────────────────────────────────
function SystemForm({
initial,
onSave,
onCancel,
}: {
initial?: SystemRecord;
onSave: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState(initial?.type ?? "P25");
const [p25, setP25] = useState<P25Config>(
initial?.type === "P25" && initial.config
? recordToP25Config(initial.config)
: DEFAULT_P25
);
const [rawJson, setRawJson] = useState(
initial?.type !== "P25" && initial?.config
? JSON.stringify(initial.config, null, 2)
: "{}"
);
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(t: string) {
setType(t);
setShowRaw(t !== "P25");
}
function toggleRaw() {
if (!showRaw) {
setRawJson(JSON.stringify(p25ConfigToRecord(p25), null, 2));
} else {
try {
setP25(recordToP25Config(JSON.parse(rawJson)));
} catch {
// keep current p25 state if parse fails
}
}
setShowRaw(!showRaw);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
let config: Record<string, unknown>;
if (type === "P25" && !showRaw) {
config = p25ConfigToRecord(p25);
} else {
config = JSON.parse(rawJson);
}
if (initial) {
await c2api.updateSystem(initial.system_id, { name, type, config });
} else {
await c2api.createSystem({ name, type, config });
}
onSave();
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid config or save failed.");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{initial ? "Edit System" : "New System"}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Westchester County P25"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Type</label>
<select
value={type}
onChange={(e) => handleTypeChange(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option>P25</option>
<option>DMR</option>
<option>NBFM</option>
</select>
</div>
</div>
{type === "P25" && !showRaw ? (
<P25Form value={p25} onChange={setP25} />
) : (
<div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
<textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
)}
{type === "P25" && (
<button
type="button"
onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors"
>
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [bootstrapped, setBootstrapped] = useState(false);
const [loading, setLoading] = useState(false);
const [bootstrapping, setBootstrapping] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (vocab !== null) return; // already loaded
setLoading(true);
try {
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(data.vocabulary_bootstrapped);
} finally {
setLoading(false);
}
}
function toggle() {
if (!open) load();
setOpen((v) => !v);
}
async function handleBootstrap() {
setBootstrapping(true);
try {
const result = await c2api.bootstrapVocabulary(systemId);
const data = await c2api.getVocabulary(systemId);
setVocab(data.vocabulary);
setPending(data.vocabulary_pending);
setBootstrapped(true);
alert(`Bootstrap added ${result.added} term(s).`);
} finally {
setBootstrapping(false);
}
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const term = newTerm.trim();
if (!term) return;
setAdding(true);
try {
await c2api.addVocabularyTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setNewTerm("");
} finally {
setAdding(false);
}
}
async function handleRemove(term: string) {
await c2api.removeVocabularyTerm(systemId, term);
setVocab((v) => (v ?? []).filter((t) => t !== term));
}
async function handleApprove(term: string) {
await c2api.approvePendingTerm(systemId, term);
setVocab((v) => (v ? [...v, term] : [term]));
setPending((p) => p.filter((t) => t.term !== term));
}
async function handleDismiss(term: string) {
await c2api.dismissPendingTerm(systemId, term);
setPending((p) => p.filter((t) => t.term !== term));
}
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Vocabulary
{vocab !== null && <span className="text-gray-600 ml-1">({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})</span>}
</span>
</button>
{open && (
<div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading</p>}
{!loading && vocab !== null && (
<>
{/* Bootstrap button */}
<div className="flex items-center gap-3">
<button
onClick={handleBootstrap}
disabled={bootstrapping}
className="bg-indigo-800 hover:bg-indigo-700 disabled:opacity-50 text-indigo-200 px-3 py-1.5 rounded-lg text-xs transition-colors"
>
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div>
{/* Approved vocabulary chips */}
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span
key={term}
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
>
{term}
<button
onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
</div>
) : (
<p className="text-gray-600 italic">No terms yet bootstrap or add manually.</p>
)}
</div>
{/* Add term */}
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
>
Add
</button>
</form>
{/* Pending induction suggestions */}
{pending.length > 0 && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-1">
{pending.map((p) => (
<div key={p.term} className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span>
<button
onClick={() => handleApprove(p.term)}
className="text-green-500 hover:text-green-400 transition-colors px-1"
>
</button>
<button
onClick={() => handleDismiss(p.term)}
className="text-gray-600 hover:text-red-400 transition-colors px-1"
>
</button>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
// ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() {
const { systems, loading } = useSystems();
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id);
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button
onClick={() => setEditing("new")}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
</div>
{editing && (
<SystemForm
initial={editing === "new" ? undefined : editing}
onSave={() => setEditing(null)}
onCancel={() => setEditing(null)}
/>
)}
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading</p>
) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p>
) : (
<div className="space-y-3">
{systems.map((s) => {
const tgCount = Array.isArray(s.config.talkgroups)
? (s.config.talkgroups as unknown[]).length
: null;
const ccCount = Array.isArray(s.config.control_channels)
? (s.config.control_channels as unknown[]).length
: null;
return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p>
{(s.config.nac || ccCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>}
{tgCount !== null && <span>· {tgCount} TGs</span>}
</p>
)}
</div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
{s.type}
</span>
</div>
<div className="mt-3 flex gap-3">
<button
onClick={() => setEditing(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(s.system_id)}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Delete
</button>
</div>
<VocabularyPanel systemId={s.system_id} />
</div>
);
})}
</div>
)}
</div>
);
}