e55412d8c7
app/map/page.tsx
Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx
Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts
Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts
Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx
Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
1288 lines
46 KiB
TypeScript
1288 lines
46 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState, Fragment } 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 })),
|
||
};
|
||
}
|
||
|
||
// ── RadioReference parser types ───────────────────────────────────────────────
|
||
|
||
interface RRTalkgroup {
|
||
dec: number;
|
||
alphaTag: string;
|
||
description: string;
|
||
tag: string;
|
||
}
|
||
|
||
interface RRCategory {
|
||
name: string;
|
||
talkgroups: RRTalkgroup[];
|
||
}
|
||
|
||
interface RRSystem {
|
||
name: string;
|
||
location: string;
|
||
sysIds: string;
|
||
systemType: string;
|
||
categories: RRCategory[];
|
||
}
|
||
|
||
function mapRRTag(rrTag: string): string {
|
||
const t = rrTag.toLowerCase();
|
||
if (t.includes("fire")) return "fire";
|
||
if (t.includes("law") || t.includes("police")) return "police";
|
||
if (t.includes("ems") || t.includes("emergency medical")) return "ems";
|
||
if (t.includes("transport") || t.includes("transit")) return "transit";
|
||
if (t.includes("public works")) return "public works";
|
||
return "other";
|
||
}
|
||
|
||
function parseRadioReference(html: string): RRSystem | null {
|
||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||
|
||
// Validate: RadioReference system pages have rrlblue header cells
|
||
if (!doc.querySelector(".rrlblue")) return null;
|
||
|
||
// System info table (first table with rrlblue headers)
|
||
const infoMap: Record<string, string> = {};
|
||
const infoTable = doc.querySelector("table.table-sm.table-bordered");
|
||
if (infoTable) {
|
||
infoTable.querySelectorAll("tr").forEach((row) => {
|
||
const th = row.querySelector("th.rrlblue");
|
||
const td = row.querySelector("td");
|
||
if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? "";
|
||
});
|
||
}
|
||
|
||
const name = infoMap["System Name"] ?? doc.title ?? "Unknown System";
|
||
const location = infoMap["Location"] ?? "";
|
||
const sysIds = infoMap["System IDs"] ?? "";
|
||
const systemType = infoMap["System Type"] ?? "";
|
||
|
||
// Talkgroup tables — find all with class rrdbTable or datatable-lite
|
||
// For each, find the nearest preceding h5 to use as category name
|
||
const tgTables = Array.from(
|
||
doc.querySelectorAll("table.rrdbTable, table.datatable-lite")
|
||
) as HTMLTableElement[];
|
||
|
||
const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[];
|
||
|
||
function categoryForTable(table: HTMLTableElement): string {
|
||
// Find the last h5 that appears before this table in document order
|
||
let best: HTMLElement | null = null;
|
||
for (const h5 of allH5s) {
|
||
const pos = h5.compareDocumentPosition(table);
|
||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5;
|
||
}
|
||
if (!best) return "Uncategorized";
|
||
const clone = best.cloneNode(true) as HTMLElement;
|
||
clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove());
|
||
return clone.textContent?.trim() || "Uncategorized";
|
||
}
|
||
|
||
const categories: RRCategory[] = [];
|
||
|
||
for (const table of tgTables) {
|
||
// Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …)
|
||
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
|
||
th.textContent?.trim().toLowerCase()
|
||
);
|
||
if (!headers.includes("dec") && !headers.includes("hex")) continue;
|
||
|
||
const catName = categoryForTable(table);
|
||
const talkgroups: RRTalkgroup[] = [];
|
||
|
||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||
const cells = Array.from(row.querySelectorAll("td"));
|
||
if (cells.length < 6) return;
|
||
|
||
// DEC cell may wrap in a Broadcastify link
|
||
const decText =
|
||
cells[0].querySelector("a")?.textContent?.trim() ??
|
||
cells[0].textContent?.trim() ??
|
||
"";
|
||
const dec = parseInt(decText.replace(/\D/g, ""), 10);
|
||
if (isNaN(dec)) return;
|
||
|
||
const alphaTag = cells[3].textContent?.trim() ?? "";
|
||
const description = cells[4].textContent?.trim() ?? "";
|
||
const tag = cells[5].textContent?.trim() ?? "";
|
||
|
||
talkgroups.push({ dec, alphaTag, description, tag });
|
||
});
|
||
|
||
if (talkgroups.length > 0) {
|
||
// Merge into an existing category with same name if present
|
||
const existing = categories.find((c) => c.name === catName);
|
||
if (existing) {
|
||
existing.talkgroups.push(...talkgroups);
|
||
} else {
|
||
categories.push({ name: catName, talkgroups });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (categories.length === 0) return null;
|
||
|
||
return { name, location, sysIds, systemType, categories };
|
||
}
|
||
|
||
// ── RadioReference import modal ───────────────────────────────────────────────
|
||
|
||
function RRImportModal({
|
||
system,
|
||
onImport,
|
||
onCancel,
|
||
}: {
|
||
system: RRSystem;
|
||
onImport: (tgs: TalkgroupEntry[]) => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [selected, setSelected] = useState<Set<string>>(
|
||
() => new Set(system.categories.map((c) => c.name))
|
||
);
|
||
|
||
function toggle(name: string) {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(name)) next.delete(name); else next.add(name);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function handleImport() {
|
||
const tgs: TalkgroupEntry[] = [];
|
||
for (const cat of system.categories) {
|
||
if (!selected.has(cat.name)) continue;
|
||
for (const tg of cat.talkgroups) {
|
||
tgs.push({
|
||
id: String(tg.dec),
|
||
name: `${cat.name.split(" - ")[0]} - ${tg.description || tg.alphaTag}`,
|
||
tag: mapRRTag(tg.tag),
|
||
});
|
||
}
|
||
}
|
||
onImport(tgs);
|
||
}
|
||
|
||
const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0);
|
||
const selectedCount = system.categories
|
||
.filter((c) => selected.has(c.name))
|
||
.reduce((s, c) => s + c.talkgroups.length, 0);
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||
{/* Header */}
|
||
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
||
<h2 className="text-white font-semibold">{system.name}</h2>
|
||
<p className="text-xs text-gray-500 mt-0.5">
|
||
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
||
</p>
|
||
{system.sysIds && (
|
||
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
|
||
)}
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
||
</p>
|
||
</div>
|
||
|
||
{/* Category list */}
|
||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
||
<div className="flex gap-3 text-xs text-indigo-400">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelected(new Set(system.categories.map((c) => c.name)))}
|
||
className="hover:text-indigo-300 transition-colors"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelected(new Set())}
|
||
className="hover:text-indigo-300 transition-colors"
|
||
>
|
||
None
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{system.categories.map((cat) => (
|
||
<label key={cat.name} className="flex items-center gap-3 cursor-pointer group py-0.5">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(cat.name)}
|
||
onChange={() => toggle(cat.name)}
|
||
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
|
||
/>
|
||
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
|
||
{cat.name}
|
||
</span>
|
||
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleImport}
|
||
disabled={selectedCount === 0}
|
||
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"
|
||
>
|
||
Import {selectedCount} talkgroup{selectedCount !== 1 ? "s" : ""}
|
||
</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>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||
|
||
function TalkgroupEditor({
|
||
talkgroups,
|
||
onChange,
|
||
}: {
|
||
talkgroups: TalkgroupEntry[];
|
||
onChange: (tgs: TalkgroupEntry[]) => void;
|
||
}) {
|
||
const [showPaste, setShowPaste] = useState(false);
|
||
const [pasteText, setPasteText] = useState("");
|
||
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
|
||
const [rrError, setRrError] = useState<string | null>(null);
|
||
const rrInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
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);
|
||
}
|
||
|
||
async function handleRRFile(file: File) {
|
||
setRrError(null);
|
||
const html = await file.text();
|
||
const parsed = parseRadioReference(html);
|
||
if (!parsed) {
|
||
setRrError(
|
||
"This doesn't look like a RadioReference trunked system page. " +
|
||
"Download the HTML from a system page on radioreference.com and try again."
|
||
);
|
||
return;
|
||
}
|
||
setRrSystem(parsed);
|
||
}
|
||
|
||
function handleRRImport(newTgs: TalkgroupEntry[]) {
|
||
onChange([...talkgroups, ...newTgs]);
|
||
setRrSystem(null);
|
||
setRrError(null);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{rrSystem && (
|
||
<RRImportModal
|
||
system={rrSystem}
|
||
onImport={handleRRImport}
|
||
onCancel={() => setRrSystem(null)}
|
||
/>
|
||
)}
|
||
|
||
<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">
|
||
{/* RadioReference file import */}
|
||
<button
|
||
type="button"
|
||
onClick={() => rrInputRef.current?.click()}
|
||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||
>
|
||
RadioReference import
|
||
</button>
|
||
<input
|
||
ref={rrInputRef}
|
||
type="file"
|
||
accept=".html,.htm"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) handleRRFile(f);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
|
||
<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>
|
||
|
||
{rrError && (
|
||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/50 rounded px-3 py-2">
|
||
{rrError}
|
||
</p>
|
||
)}
|
||
|
||
{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, paste from RadioReference, or use the RadioReference import button.
|
||
</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,
|
||
createOnly,
|
||
onSave,
|
||
onCancel,
|
||
}: {
|
||
initial?: SystemRecord;
|
||
createOnly?: boolean;
|
||
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 && !createOnly) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||
|
||
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">{title}</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>
|
||
);
|
||
}
|
||
|
||
// ── Preferred bot token panel ─────────────────────────────────────────────────
|
||
|
||
interface TokenOption {
|
||
token_id: string;
|
||
name: string;
|
||
in_use: boolean;
|
||
}
|
||
|
||
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
|
||
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
|
||
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
async function load() {
|
||
if (tokens !== null) return;
|
||
try {
|
||
const data = await c2api.getTokens();
|
||
setTokens(data as TokenOption[]);
|
||
} catch {
|
||
setTokens([]);
|
||
}
|
||
}
|
||
|
||
function toggle() {
|
||
if (!open) load();
|
||
setOpen((v) => !v);
|
||
}
|
||
|
||
async function handleSet(tokenId: string) {
|
||
setSaving(true);
|
||
try {
|
||
await c2api.setPreferredToken(tokenId, systemId);
|
||
setPreferredId(tokenId);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleClear() {
|
||
if (!preferredId) return;
|
||
setSaving(true);
|
||
try {
|
||
await c2api.setPreferredToken(preferredId, "_none");
|
||
setPreferredId(null);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||
|
||
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>
|
||
Preferred Bot Token
|
||
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
|
||
</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="mt-3 space-y-2 font-mono text-xs">
|
||
{tokens === null ? (
|
||
<p className="text-gray-600 italic">Loading tokens…</p>
|
||
) : tokens.length === 0 ? (
|
||
<p className="text-gray-600 italic">No tokens in pool.</p>
|
||
) : (
|
||
<>
|
||
<p className="text-gray-600">
|
||
When a node on this system joins a voice channel, this token is tried first.
|
||
</p>
|
||
<div className="space-y-1.5">
|
||
{tokens.map((t) => (
|
||
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name={`preferred-token-${systemId}`}
|
||
checked={preferredId === t.token_id}
|
||
onChange={() => handleSet(t.token_id)}
|
||
disabled={saving}
|
||
className="accent-indigo-500"
|
||
/>
|
||
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
||
{t.name}
|
||
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{preferredId && (
|
||
<button
|
||
onClick={handleClear}
|
||
disabled={saving}
|
||
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
||
>
|
||
Clear preference (use any free token)
|
||
</button>
|
||
)}
|
||
{!preferredId && (
|
||
<p className="text-gray-700">No preference — any free token will be used.</p>
|
||
)}
|
||
{currentToken && (
|
||
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
// ── Source call audio player ──────────────────────────────────────────────────
|
||
|
||
function SourceCallPlayer({ callId }: { callId: string }) {
|
||
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
async function toggle() {
|
||
if (!open && !call) {
|
||
setLoading(true);
|
||
try {
|
||
const c = await c2api.getCall(callId);
|
||
setCall(c as unknown as typeof call);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
setOpen((v) => !v);
|
||
}
|
||
|
||
const transcript = call?.transcript_corrected || call?.transcript;
|
||
|
||
return (
|
||
<div className="text-xs">
|
||
<button
|
||
onClick={toggle}
|
||
disabled={loading}
|
||
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
|
||
title={callId}
|
||
>
|
||
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||
</button>
|
||
{open && call && (
|
||
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
|
||
{call.audio_url ? (
|
||
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
||
) : (
|
||
<p className="text-gray-600 italic">No audio</p>
|
||
)}
|
||
{transcript && (
|
||
<p className="text-gray-500 italic line-clamp-2">{transcript}</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;
|
||
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 && (
|
||
<>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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.length > 0 && (
|
||
<div>
|
||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
||
Induction suggestions ({pending.length})
|
||
</p>
|
||
<div className="space-y-2">
|
||
{pending.map((p) => (
|
||
<div key={p.term} className="space-y-1">
|
||
<div 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>
|
||
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||
<div className="pl-1 space-y-1">
|
||
{p.source_call_ids.map((id: string) => (
|
||
<Fragment key={id}>
|
||
<SourceCallPlayer callId={id} />
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Systems list page ─────────────────────────────────────────────────────────
|
||
|
||
export default function SystemsPage() {
|
||
const { systems, loading } = useSystems();
|
||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||
|
||
async function handleDelete(id: string) {
|
||
if (!confirm("Delete this system?")) return;
|
||
await c2api.deleteSystem(id);
|
||
}
|
||
|
||
function openEdit(s: SystemRecord) {
|
||
setEditing(s);
|
||
setEditIsDuplicate(false);
|
||
}
|
||
|
||
function openDuplicate(s: SystemRecord) {
|
||
setEditing({ ...s, name: `Copy of ${s.name}` });
|
||
setEditIsDuplicate(true);
|
||
}
|
||
|
||
function closeEdit() {
|
||
setEditing(null);
|
||
setEditIsDuplicate(false);
|
||
}
|
||
|
||
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"); setEditIsDuplicate(false); }}
|
||
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}
|
||
createOnly={editIsDuplicate}
|
||
onSave={closeEdit}
|
||
onCancel={closeEdit}
|
||
/>
|
||
)}
|
||
|
||
{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={() => openEdit(s)}
|
||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => openDuplicate(s)}
|
||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||
>
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(s.system_id)}
|
||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
|
||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||
<VocabularyPanel systemId={s.system_id} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|