Files
server-26/drb-frontend/app/systems/page.tsx
T
Logan CusanoandClaude Opus 5 964343c819
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
area_context v2 + Maps place verification (server-26#36, #37)
#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:

  area_context: { municipality?, county?, state?,
                  center?, radius_km?, resolved_from?, resolved_at?,
                  local_knowledge?: [{term, meaning}] }

`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.

Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.

The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.

#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.

No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.

Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.

The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.

38 new tests; 240 pass. Frontend typechecks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:43:59 -04:00

1792 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { 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 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&apos;s own list.
Leave blank to inherit the system&apos;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>
)}
</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>
);
}