796 lines
29 KiB
TypeScript
796 lines
29 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|
||
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
||
|
||
interface SystemAiFlags {
|
||
stt_enabled?: boolean;
|
||
correlation_enabled?: boolean;
|
||
}
|
||
|
||
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
|
||
const [flags, setFlags] = useState<SystemAiFlags>(initial);
|
||
const [saving, setSaving] = useState<string | null>(null);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
|
||
setSaving(key);
|
||
try {
|
||
const result = await c2api.setSystemAiFlags(systemId, { [key]: value });
|
||
setFlags(result.ai_flags as SystemAiFlags);
|
||
} finally {
|
||
setSaving(null);
|
||
}
|
||
}
|
||
|
||
async function handleClear(key: keyof SystemAiFlags) {
|
||
setSaving(key);
|
||
try {
|
||
const result = await c2api.setSystemAiFlags(systemId, { [key]: null });
|
||
setFlags(result.ai_flags as SystemAiFlags);
|
||
} finally {
|
||
setSaving(null);
|
||
}
|
||
}
|
||
|
||
const rows: { key: keyof SystemAiFlags; label: string }[] = [
|
||
{ key: "stt_enabled", label: "Speech-to-Text" },
|
||
{ key: "correlation_enabled", label: "Incident Correlation" },
|
||
];
|
||
|
||
return (
|
||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||
>
|
||
<span>{open ? "▲" : "▼"}</span>
|
||
<span>AI Flags</span>
|
||
{(flags.stt_enabled === false || flags.correlation_enabled === false) && (
|
||
<span className="ml-1.5 text-yellow-600 font-bold">!</span>
|
||
)}
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="mt-3 space-y-2 font-mono text-xs">
|
||
{rows.map(({ key, label }) => {
|
||
const override = flags[key];
|
||
const isSet = override !== undefined;
|
||
const isOn = override !== false;
|
||
return (
|
||
<div key={key} className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => handleToggle(key, !isOn)}
|
||
disabled={saving === key}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||
isOn ? "bg-indigo-600" : "bg-gray-700"
|
||
}`}
|
||
>
|
||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
||
</button>
|
||
<span className="text-gray-300 flex-1">{label}</span>
|
||
{isSet ? (
|
||
<button
|
||
onClick={() => handleClear(key)}
|
||
disabled={saving === key}
|
||
className="text-gray-600 hover:text-gray-400 transition-colors"
|
||
title="Clear override (inherit global)"
|
||
>
|
||
reset
|
||
</button>
|
||
) : (
|
||
<span className="text-gray-700">inherits global</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ── 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">
|
||
<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>
|
||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||
<VocabularyPanel systemId={s.system_id} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|