Frontend redesign chunk 4: navigation and routing
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy to VM (push) Failing after 11m50s

Rewrite Nav.tsx to the five-destination IA from UI_REDESIGN.md §3 (Live,
Incidents, Archive, Watch, Network) on tokens/sans type, with Settings,
Admin, Trips and Profile moved into the avatar dropdown instead of sitting
as nav peers. Network stays gated to admin/operator, matching the write
boundary its constituent pages (nodes/systems/tokens) already had.

Delete app/dashboard/page.tsx — its incident cards become the Live rail,
its node cards become Network, its call table becomes Archive; nothing on
it is unique. Add app/map/page.tsx -> redirect('/') and rewrite
app/calls/page.tsx -> redirect('/incidents') (Archive/search is blocked on
backend work, chunk 12).

ChromeSwitcher now gives a signed-in user at "/" the app shell instead of
marketing chrome; app/page.tsx branches the same way, sending a signed-in
provisioned user to /incidents as an honest interim until the Live screen
itself lands (chunk 6) — marketing content and behavior for signed-out
visitors is unchanged.

Left the light-mode !important overrides in globals.css in place past this
chunk (deviating from the chunk 4 acceptance criteria) — they still back
every page outside this redesign's 11-chunk scope (settings, admin,
profile, marketing). Deleting them now would break light mode on all of
those. Logged in DEFERRED.md.

Per UI_REDESIGN.md chunk 4.
This commit is contained in:
Logan Cusano
2026-08-19 23:01:13 -04:00
parent 8fdedee25b
commit eaae452d4e
6 changed files with 151 additions and 571 deletions
+8 -259
View File
@@ -1,261 +1,10 @@
"use client";
import { redirect } from "next/navigation";
import { useState, useMemo } from "react";
import { useCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { CallRow } from "@/components/CallRow";
import { useAuth } from "@/components/AuthProvider";
import type { CallRecord } from "@/lib/types";
const inputCls =
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
const q = filters.query.trim().toLowerCase();
const tgid = filters.tgid.trim();
return calls.filter((c) => {
// System filter
if (filters.systemId && c.system_id !== filters.systemId) return false;
// TGID filter (exact match on the number)
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
// Free-text: talkgroup name, node_id, transcript, tags
if (q) {
const hay = [
c.talkgroup_name ?? "",
c.node_id,
c.transcript ?? "",
c.transcript_corrected ?? "",
...(c.tags ?? []),
].join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
interface Filters {
query: string;
tgid: string;
systemId: string;
dateFrom: string;
dateTo: string;
}
const DEFAULT_FILTERS: Filters = {
query: "",
tgid: "",
systemId: "",
dateFrom: "",
dateTo: "",
};
function isActive(f: Filters) {
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
}
export default function CallsPage() {
const [limitCount, setLimitCount] = useState(100);
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom + "T00:00:00") : undefined;
const dateTo = filters.dateTo ? new Date(filters.dateTo + "T23:59:59") : undefined;
const { calls, loading } = useCalls(limitCount, dateFrom, dateTo);
const { systems } = useSystems();
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const [showFilters, setShowFilters] = useState(false);
function set<K extends keyof Filters>(key: K, value: string) {
setFilters((f) => ({ ...f, [key]: value }));
}
const active = calls.filter((c) => c.status === "active");
const ended = calls.filter((c) => c.status === "ended");
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
const activeFilters = isActive(filters);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
<button
onClick={() => setShowFilters((v) => !v)}
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
activeFilters
? "border-indigo-600 bg-indigo-950 text-indigo-300"
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
}`}
>
{showFilters ? "Hide filters" : "Filter"}
{activeFilters && " •"}
</button>
</div>
</div>
{/* Filter bar */}
{showFilters && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Text search */}
<div className="lg:col-span-2">
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
<input
type="text"
value={filters.query}
onChange={(e) => set("query", e.target.value)}
placeholder="fire, Engine 5, dispatch…"
className={inputCls}
/>
</div>
{/* TGID */}
<div>
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
<input
type="number"
value={filters.tgid}
onChange={(e) => set("tgid", e.target.value)}
placeholder="e.g. 9048"
className={inputCls}
/>
</div>
{/* System */}
<div>
<label className="text-xs text-gray-500 block mb-1">System</label>
<select
value={filters.systemId}
onChange={(e) => set("systemId", e.target.value)}
className={inputCls}
>
<option value="">All systems</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name}</option>
))}
</select>
</div>
{/* Date from */}
<div>
<label className="text-xs text-gray-500 block mb-1">From date</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => set("dateFrom", e.target.value)}
className={inputCls}
/>
</div>
{/* Date to */}
<div>
<label className="text-xs text-gray-500 block mb-1">To date</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => set("dateTo", e.target.value)}
className={inputCls}
/>
</div>
</div>
{activeFilters && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-gray-500 font-mono">
{filtered.length} of {ended.length} calls match
</p>
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
>
Clear all
</button>
</div>
)}
</div>
)}
{/* Live calls — never filtered */}
{active.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
Live ({active.length})
</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{active.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</div>
</section>
)}
{/* History */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
</h2>
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p>
) : filtered.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
</p>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</div>
{ended.length >= limitCount && (
<button
onClick={() => setLimitCount((n) => n + 100)}
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
>
Load more
</button>
)}
</>
)}
</section>
</div>
);
// Destination for /calls is Archive (/search) per UI_REDESIGN.md §3/§5.4, but
// /search needs paged server-side search (order_by/limit/cursor on
// internal/firestore.py, a GET /calls/search route) that doesn't exist yet —
// tracked as UI_REDESIGN.md chunk 12, blocked on Gitea #17/#18. Until then
// this redirects to Incidents, same as the chunk 4 spec's interim.
export default function CallsPageRedirect() {
redirect("/incidents");
}