Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79 decision 14) blocks putting a price or an unbuilt entitlement claim on a surface a reader can see, and requires that unverified machine assertions be labelled as such on the same screen as the assertion. The pricing leg was already met — /pricing and both homepage CTAs stopped quoting the invented catalog. Condition A2 was not: a search of the whole frontend for a "machine-generated" or "unverified" qualifier returned zero hits. Every transcript, summary, title, location, unit list and vehicle list is pipeline output that no human reviews, and entity-name accuracy in those transcripts has never been measured (server-26#48) — yet all of it was rendered to the reader as plain fact. Unqualified machine assertions about real incidents and real people is the exposure Gate A exists to stop. A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on the same screen as the output (a footnote elsewhere does not satisfy A1's "same screen" standard). Three variants for three shapes of surface, all saying the same thing; the "popup" variant uses fixed grays because a Leaflet popup is stock-white in both themes. Covered: - incident detail: under the summary (covers summary, title, location, units on scene/cleared, vehicles, tags) and above the call spine - incident list: above the timeline groups - Archive (/calls): above the transcript rows - node detail: above the Recent Calls table - Watch//alerts: above the events table, whose Snippet column is transcript text and whose keyword match was made against it - Live map: the desktop incident rail, pinned above the scroll area so it cannot be scrolled off the screen it qualifies; the mobile drawer; the incident marker popup; the incident-path stop popup - /systems: the source-call transcript preview - /features: the two marketing sections that describe the AI pipeline A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline: - /faq: the retention answer promised 7/90/365-day windows. There is no TTL and no deletion sweep anywhere in the product (server-26#44), so the answer now states plainly that nothing is deleted automatically and marks per-plan retention as not yet available. - /settings/billing: the plan cards' claims — custom retention, SSO/SAML, uptime SLA, data residency — are marked not-yet-available next to the plan that makes them. Labelling only. No retention, SSO, SLA or residency was built; no billing, Stripe or checkout code was touched (Gate B still bars charging anyone); no price was added anywhere; no Python was touched. Both themes verified against the light-mode !important overrides in globals.css, which are untouched. tsc --noEmit clean. Refs: server-26#46, server-26#44, server-26#48 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1797 lines
66 KiB
TypeScript
1797 lines
66 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState, Fragment } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useSystems } from "@/lib/useSystems";
|
||
import { c2api } from "@/lib/c2api";
|
||
import { useAuth } from "@/components/AuthProvider";
|
||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||
import type {
|
||
AreaContext,
|
||
LocalKnowledgeEntry,
|
||
SystemRecord,
|
||
TalkgroupPending,
|
||
VocabularyPendingTerm,
|
||
} from "@/lib/types";
|
||
|
||
// ── P25 structured config types ───────────────────────────────────────────────
|
||
|
||
interface TalkgroupEntry {
|
||
id: string;
|
||
name: string;
|
||
tag: string;
|
||
// Local knowledge for the transcript corrector (server-26#36). Optional on
|
||
// every talkgroup: unset means "inherit the system's", which is the whole
|
||
// point of the scope rule — talkgroup narrows, it does not replace.
|
||
vocabulary?: string[];
|
||
area_context?: AreaContext;
|
||
}
|
||
|
||
/** Comma-separated text field <-> string[], the shape the API stores. */
|
||
const listToText = (v?: string[]) => (v ?? []).join(", ");
|
||
const textToList = (v: string) =>
|
||
v.split(",").map((x) => x.trim()).filter(Boolean);
|
||
|
||
/**
|
||
* Local knowledge is one entry per line, `term — meaning`.
|
||
*
|
||
* A bare term is valid — half the information is still worth having, and
|
||
* forcing a meaning would make people invent one. An em dash, a spaced hyphen
|
||
* or an equals sign all separate; whichever comes first wins.
|
||
*/
|
||
const knowledgeToText = (v?: LocalKnowledgeEntry[]) =>
|
||
(v ?? []).map((e) => (e.meaning ? `${e.term} — ${e.meaning}` : e.term)).join("\n");
|
||
|
||
function textToKnowledge(v: string): LocalKnowledgeEntry[] {
|
||
const out: LocalKnowledgeEntry[] = [];
|
||
for (const line of v.split("\n")) {
|
||
const match = line.match(/^(.*?)\s*(?:—|–|\s-\s|=)\s*(.*)$/);
|
||
const term = (match ? match[1] : line).trim();
|
||
const meaning = match ? match[2].trim() : "";
|
||
if (term) out.push(meaning ? { term, meaning } : { term });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Fold the pre-#36 shape forward on load.
|
||
*
|
||
* `roads[]` and `landmarks[]` were the original fields and real systems still
|
||
* have them stored. Showing them as local-knowledge lines means an operator
|
||
* sees what they already entered instead of an empty box, and the next save
|
||
* writes them in the new shape.
|
||
*/
|
||
function migrateArea(a?: AreaContext & { roads?: string[]; landmarks?: string[] }): AreaContext {
|
||
if (!a) return {};
|
||
const legacy = [...(a.roads ?? []), ...(a.landmarks ?? [])].map((term) => ({ term }));
|
||
if (!legacy.length) return a;
|
||
const seen = new Set((a.local_knowledge ?? []).map((e) => e.term.toLowerCase()));
|
||
const { roads: _roads, landmarks: _landmarks, ...rest } = a;
|
||
return {
|
||
...rest,
|
||
local_knowledge: [
|
||
...(a.local_knowledge ?? []),
|
||
...legacy.filter((e) => !seen.has(e.term.toLowerCase())),
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Drop an area_context whose every field is blank, so we don't store noise.
|
||
*
|
||
* Also strips the derived anchor: `center`/`radius_km`/`resolved_*` belong to
|
||
* the backend, which geocodes them from the place and merges them back. Sending
|
||
* them up would be the frontend deciding what is in a system document.
|
||
*/
|
||
function cleanArea(a?: AreaContext): AreaContext | undefined {
|
||
if (!a) return undefined;
|
||
const out: AreaContext = {};
|
||
if (a.municipality?.trim()) out.municipality = a.municipality.trim();
|
||
if (a.county?.trim()) out.county = a.county.trim();
|
||
if (a.state?.trim()) out.state = a.state.trim();
|
||
if (a.local_knowledge?.length) out.local_knowledge = a.local_knowledge;
|
||
return Object.keys(out).length ? out : undefined;
|
||
}
|
||
|
||
/** What the backend resolved this area to, in one line. */
|
||
function anchorLabel(a?: AreaContext): string {
|
||
if (!a) return "";
|
||
const place = [a.municipality, a.county, a.state].filter(Boolean).join(", ");
|
||
if (!place) return "no area set — location checking is off for this scope";
|
||
if (a.center && a.radius_km) return `anchored to ${place}, ${Math.round(a.radius_km)} km radius`;
|
||
if (a.resolved_from) return `${place} — too wide to check locations against, so that check is skipped`;
|
||
return `${place} — not yet resolved`;
|
||
}
|
||
|
||
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;
|
||
vocabulary?: string[]; area_context?: AreaContext;
|
||
}>).map((tg) => ({
|
||
id: String(tg.id),
|
||
name: tg.name,
|
||
tag: tg.tag ?? "other",
|
||
vocabulary: tg.vocabulary ?? [],
|
||
area_context: migrateArea(tg.area_context),
|
||
}))
|
||
: [],
|
||
};
|
||
}
|
||
|
||
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) => {
|
||
const area = cleanArea(tg.area_context);
|
||
const vocab = (tg.vocabulary ?? []).filter(Boolean);
|
||
return {
|
||
id: parseInt(tg.id, 10),
|
||
name: tg.name,
|
||
tag: tg.tag,
|
||
// Omitted rather than written empty: an absent key is what
|
||
// resolve_context() reads as "inherit from the system".
|
||
...(vocab.length ? { vocabulary: vocab } : {}),
|
||
...(area ? { area_context: area } : {}),
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
/** True when this talkgroup overrides anything, so the row can say so. */
|
||
function hasLocalKnowledge(tg: TalkgroupEntry): boolean {
|
||
return Boolean((tg.vocabulary ?? []).length || cleanArea(tg.area_context));
|
||
}
|
||
|
||
/** One labelled field in the local-knowledge grid. */
|
||
function LocalKnowledgeField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
multiline,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
multiline?: boolean;
|
||
}) {
|
||
const cls =
|
||
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
||
return (
|
||
<label className="block">
|
||
<span className="text-xs text-gray-500 font-sans">{label}</span>
|
||
{multiline ? (
|
||
<textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
rows={4}
|
||
className={`${cls} font-mono resize-y`}
|
||
/>
|
||
) : (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className={cls}
|
||
/>
|
||
)}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// ── 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 [expanded, setExpanded] = useState<number | null>(null);
|
||
const rrInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
function addRow() {
|
||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||
}
|
||
|
||
function updateArea(i: number, field: "municipality" | "county" | "state", value: string) {
|
||
const updated = [...talkgroups];
|
||
updated[i] = { ...updated[i], area_context: { ...updated[i].area_context, [field]: value } };
|
||
onChange(updated);
|
||
}
|
||
|
||
function updateAreaKnowledge(i: number, value: string) {
|
||
const updated = [...talkgroups];
|
||
updated[i] = {
|
||
...updated[i],
|
||
area_context: { ...updated[i].area_context, local_knowledge: textToKnowledge(value) },
|
||
};
|
||
onChange(updated);
|
||
}
|
||
|
||
function updateRowList(i: number, field: "vocabulary", value: string) {
|
||
const updated = [...talkgroups];
|
||
updated[i] = { ...updated[i], [field]: textToList(value) };
|
||
onChange(updated);
|
||
}
|
||
|
||
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) => (
|
||
<Fragment key={i}>
|
||
<tr 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"
|
||
title="Local knowledge for the transcript corrector"
|
||
onClick={() => setExpanded(expanded === i ? null : i)}
|
||
className={`transition-colors font-bold mr-2 ${
|
||
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
|
||
}`}
|
||
>
|
||
{expanded === i ? "▾" : "▸"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeRow(i)}
|
||
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
|
||
>
|
||
×
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{/* Local knowledge, collapsed by default: a system can carry 125
|
||
talkgroups and most inherit the system's context rather than
|
||
override it. */}
|
||
{expanded === i && (
|
||
<tr className="border-t border-gray-800 bg-gray-900/60">
|
||
<td colSpan={4} className="px-3 py-3">
|
||
<p className="text-xs text-gray-500 mb-2 font-sans">
|
||
Given to the transcript corrector for this talkgroup only, ranked
|
||
<span className="text-gray-300"> above</span> the system's own list.
|
||
Leave blank to inherit the system's.
|
||
</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||
<LocalKnowledgeField
|
||
label="Municipality"
|
||
value={tg.area_context?.municipality ?? ""}
|
||
onChange={(v) => updateArea(i, "municipality", v)}
|
||
placeholder="Ossining"
|
||
/>
|
||
<LocalKnowledgeField
|
||
label="County"
|
||
value={tg.area_context?.county ?? ""}
|
||
onChange={(v) => updateArea(i, "county", v)}
|
||
placeholder="Westchester"
|
||
/>
|
||
<LocalKnowledgeField
|
||
label="State"
|
||
value={tg.area_context?.state ?? ""}
|
||
onChange={(v) => updateArea(i, "state", v)}
|
||
placeholder="New York"
|
||
/>
|
||
<div className="md:col-span-2">
|
||
<LocalKnowledgeField
|
||
label="Local knowledge — one per line, term — what it is"
|
||
multiline
|
||
value={knowledgeToText(tg.area_context?.local_knowledge)}
|
||
onChange={(v) => updateAreaKnowledge(i, v)}
|
||
placeholder={
|
||
"Snowden Avenue — residential street\nSing Sing — state prison\n11-X-ray — MTA PD patrol unit"
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<LocalKnowledgeField
|
||
label="Unit call signs & local terms"
|
||
value={listToText(tg.vocabulary)}
|
||
onChange={(v) => updateRowList(i, "vocabulary", v)}
|
||
placeholder="Post 4, 11-X-ray, Car 7"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</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>
|
||
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Area context panel ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* System-wide ground truth for the transcript corrector (server-26#36).
|
||
*
|
||
* This is the fallback every talkgroup inherits. A talkgroup that covers one
|
||
* municipality inside a multi-county system overrides it from the talkgroup
|
||
* table in the edit form, and its entries rank above these.
|
||
*/
|
||
function AreaContextPanel({ systemId }: { systemId: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [area, setArea] = useState<AreaContext | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function toggle() {
|
||
const next = !open;
|
||
setOpen(next);
|
||
if (!next || area !== null) return;
|
||
setLoading(true);
|
||
try {
|
||
const data = await c2api.getAreaContext(systemId);
|
||
setArea(migrateArea(data.area_context));
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (!area) return;
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
// The response carries the anchor this edit produced, so the readout
|
||
// below reflects what the backend actually resolved rather than what was
|
||
// typed.
|
||
const saved = await c2api.updateAreaContext(systemId, area);
|
||
setArea(saved.area_context ?? area);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
const filled = area
|
||
? [area.municipality, area.county, area.state, area.local_knowledge?.length].filter(Boolean).length
|
||
: 0;
|
||
|
||
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>
|
||
Local Area
|
||
{area !== null && (
|
||
<span className="text-gray-600 ml-1">
|
||
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="mt-3 space-y-3 text-xs">
|
||
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
||
{area && (
|
||
<>
|
||
<p className="text-gray-500">
|
||
Given to the transcript corrector for every talkgroup on this system.
|
||
Whisper mishears local names constantly — naming them here is what lets
|
||
them be put back.
|
||
</p>
|
||
<p className="text-gray-500">
|
||
Fill this in <span className="text-gray-300">only if it is true of every
|
||
talkgroup</span> on the system. One town, one department — fill it once here.
|
||
A system spanning several counties — leave it blank and describe each
|
||
talkgroup in the edit form instead.
|
||
</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||
<LocalKnowledgeField
|
||
label="Municipality"
|
||
value={area.municipality ?? ""}
|
||
onChange={(v) => setArea({ ...area, municipality: v })}
|
||
placeholder="Ossining"
|
||
/>
|
||
<LocalKnowledgeField
|
||
label="County"
|
||
value={area.county ?? ""}
|
||
onChange={(v) => setArea({ ...area, county: v })}
|
||
placeholder="Westchester"
|
||
/>
|
||
<LocalKnowledgeField
|
||
label="State"
|
||
value={area.state ?? ""}
|
||
onChange={(v) => setArea({ ...area, state: v })}
|
||
placeholder="New York"
|
||
/>
|
||
</div>
|
||
<LocalKnowledgeField
|
||
label="Local knowledge — one per line, term — what it is"
|
||
multiline
|
||
value={knowledgeToText(area.local_knowledge)}
|
||
onChange={(v) => setArea({ ...area, local_knowledge: textToKnowledge(v) })}
|
||
placeholder={
|
||
"Route 9 — main north-south highway\nPhelps — Phelps Hospital, Sleepy Hollow\nthe flats — low-lying area by the river"
|
||
}
|
||
/>
|
||
{/* The anchor is the backend's answer to what was typed above, and
|
||
it decides whether location checking runs at all — so it is
|
||
worth showing rather than leaving as invisible state. */}
|
||
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
|
||
<button
|
||
type="button"
|
||
onClick={save}
|
||
disabled={saving}
|
||
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"
|
||
>
|
||
{saving ? "Saving…" : "Save area"}
|
||
</button>
|
||
</>
|
||
)}
|
||
{error && <p className="text-red-400 font-mono">{error}</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Terms the place verifier and the induction loop proposed, per talkgroup.
|
||
*
|
||
* Approval is always a person's decision and always lands on the talkgroup that
|
||
* proposed it — nothing here promotes a term to the system (server-26#37). A
|
||
* wrong term on one channel misleads one channel; the same term system-wide
|
||
* misleads every channel on it, including one on the far side of a statewide
|
||
* system.
|
||
*/
|
||
function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [rows, setRows] = useState<TalkgroupPending[] | null>(null);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function load() {
|
||
try {
|
||
const data = await c2api.getTalkgroupPending(systemId);
|
||
setRows(data.talkgroups ?? []);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
}
|
||
|
||
async function toggle() {
|
||
const next = !open;
|
||
setOpen(next);
|
||
if (next && rows === null) await load();
|
||
}
|
||
|
||
async function act(talkgroupId: number, term: string, approve: boolean) {
|
||
setBusy(`${talkgroupId}:${term}`);
|
||
setError(null);
|
||
try {
|
||
if (approve) await c2api.approveTalkgroupTerm(systemId, talkgroupId, term);
|
||
else await c2api.dismissTalkgroupTerm(systemId, talkgroupId, term);
|
||
await load();
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
||
|
||
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>
|
||
Proposed Terms
|
||
{rows !== null && (
|
||
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
|
||
({total > 0 ? `${total} awaiting review` : "none"})
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="mt-3 space-y-3 text-xs">
|
||
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
||
{rows !== null && total === 0 && (
|
||
<p className="text-gray-600 italic">
|
||
Nothing proposed yet. Terms appear here when a corrected place name
|
||
turns out to be real nearby, or when the induction loop spots one in
|
||
recent traffic.
|
||
</p>
|
||
)}
|
||
{(rows ?? []).map((row) => (
|
||
<div key={row.talkgroup_id}>
|
||
<p className="text-gray-400 font-mono mb-1">
|
||
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
||
</p>
|
||
<ul className="space-y-1">
|
||
{row.pending.map((p) => (
|
||
<li
|
||
key={p.term}
|
||
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
|
||
>
|
||
<span className="flex-1">
|
||
<span className="text-white font-mono">{p.term}</span>
|
||
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
|
||
<span className="text-gray-700 ml-2">{p.source}</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||
onClick={() => act(row.talkgroup_id, p.term, true)}
|
||
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-40 font-semibold"
|
||
>
|
||
Add
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||
onClick={() => act(row.talkgroup_id, p.term, false)}
|
||
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
|
||
>
|
||
Drop
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
{error && <p className="text-red-400 font-mono">{error}</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||
const router = useRouter();
|
||
const { systems, loading } = useSystems();
|
||
|
||
useEffect(() => {
|
||
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
|
||
}, [authLoading, isAdmin, isOperator, router]);
|
||
|
||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||
|
||
// Every hook must run before this guard. React tracks hooks by call order,
|
||
// so returning early on the first render and then reaching a useState on the
|
||
// next one is error #310 ("rendered more hooks than during the previous
|
||
// render") -- which crashed this whole page to a blank client-exception
|
||
// screen the moment auth resolved.
|
||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||
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 ?? {}} />
|
||
<AreaContextPanel systemId={s.system_id} />
|
||
<TalkgroupPendingPanel systemId={s.system_id} />
|
||
<VocabularyPanel systemId={s.system_id} />
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|