"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): 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 { 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 = {}; 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>( () => 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 (
{/* Header */}

{system.name}

{system.systemType}{system.location ? ` · ${system.location}` : ""}

{system.sysIds && (

System IDs: {system.sysIds}

)}

{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups

{/* Category list */}

Talkgroup Categories

{system.categories.map((cat) => ( ))}
{/* Footer */}
); } /** 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 (