diff --git a/drb-frontend/app/calls/page.tsx b/drb-frontend/app/calls/page.tsx index 3fff239..acac09a 100644 --- a/drb-frontend/app/calls/page.tsx +++ b/drb-frontend/app/calls/page.tsx @@ -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(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(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 ( -
-
-

Calls

-
- {calls.length} loaded - -
-
- - {/* Filter bar */} - {showFilters && ( -
-
- {/* Text search */} -
- - set("query", e.target.value)} - placeholder="fire, Engine 5, dispatch…" - className={inputCls} - /> -
- - {/* TGID */} -
- - set("tgid", e.target.value)} - placeholder="e.g. 9048" - className={inputCls} - /> -
- - {/* System */} -
- - -
- - {/* Date from */} -
- - set("dateFrom", e.target.value)} - className={inputCls} - /> -
- - {/* Date to */} -
- - set("dateTo", e.target.value)} - className={inputCls} - /> -
-
- - {activeFilters && ( -
-

- {filtered.length} of {ended.length} calls match -

- -
- )} -
- )} - - {/* Live calls — never filtered */} - {active.length > 0 && ( -
-

- Live ({active.length}) -

-
- - - - - - - - - - - - - - {active.map((c) => ( - - ))} - -
TimeTalkgroupSystemNodeDurationAudio
-
-
- )} - - {/* History */} -
-

- History{activeFilters && ({filtered.length} filtered)} -

- {loading ? ( -

Loading…

- ) : filtered.length === 0 ? ( -

- {activeFilters ? "No calls match the current filters." : "No calls recorded yet."} -

- ) : ( - <> -
- - - - - - - - - - - - - {filtered.map((c) => ( - - ))} - -
TimeTalkgroupSystemNodeDurationAudio
-
- {ended.length >= limitCount && ( - - )} - - )} -
-
- ); +// 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"); } diff --git a/drb-frontend/app/dashboard/page.tsx b/drb-frontend/app/dashboard/page.tsx deleted file mode 100644 index 5bee5d2..0000000 --- a/drb-frontend/app/dashboard/page.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes"; -import { useCalls, useActiveCalls } from "@/lib/useCalls"; -import { useSystems } from "@/lib/useSystems"; -import { useActiveIncidents } from "@/lib/useIncidents"; -import { NodeCard } from "@/components/NodeCard"; -import { CallRow } from "@/components/CallRow"; -import { NodeConfigModal } from "@/components/NodeConfigModal"; -import { TypeBadge } from "@/components/IncidentBadges"; -import { severityBadge, severityRank } from "@/lib/severity"; -import { useState } from "react"; -import type { NodeRecord, IncidentRecord } from "@/lib/types"; -import { useAuth } from "@/components/AuthProvider"; -import { PageHeader } from "@/components/ui/PageHeader"; -import { Card } from "@/components/ui/Card"; -import { Badge } from "@/components/ui/Badge"; -import { Button } from "@/components/ui/Button"; -import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; - -function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { - return ( - -

{label}

-

{value}

-
- ); -} - -function fmtTime(iso: string) { - try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } - catch { return iso; } -} - -function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) { - const router = useRouter(); - return ( - router.push(`/incidents/${incident.incident_id}`)}> -
- - {severityBadge(incident.severity)} -
-

{incident.title ?? "Untitled incident"}

-

- {fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} -

-
- ); -} - -export default function DashboardPage() { - const { nodes, error: nodesError } = useNodes(); - const { nodes: pending } = useUnconfiguredNodes(); - const { calls, error: callsError } = useCalls(20); - const activeCalls = useActiveCalls(); - const { systems, error: systemsError } = useSystems(); - const activeIncidents = useActiveIncidents(); - const [configNode, setConfigNode] = useState(null); - - const { isAdmin } = useAuth(); - const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); - const onlineCount = nodes.filter((n) => n.status !== "offline").length; - - const fsError = nodesError ?? callsError ?? systemsError; - - // Worst-first: the incident that most needs a human's attention leads the panel. - const sortedIncidents = [...activeIncidents].sort( - (a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at) - ); - const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length; - - return ( -
- 0 && {notableIncidentCount} moderate+ active} - /> - - {fsError && } - - {/* Pending config banner */} - {pending.length > 0 && ( -
-

- {pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration. -

- -
- )} - - {/* Stats */} -
- 0 ? "text-orange-400" : undefined} /> - - 0 ? "text-orange-400" : undefined} /> - -
- - {/* Active incidents — the primary "what's happening" view */} -
-
-

Active Incidents

- - View all → - -
- {sortedIncidents.length === 0 ? ( - - ) : ( -
- {sortedIncidents.slice(0, 6).map((inc) => ( - - ))} -
- )} -
- - {/* Nodes */} -
-

Nodes

- {nodes.length === 0 ? ( - - ) : ( -
- {nodes.map((n) => ( - - ))} -
- )} -
- - {/* Recent calls */} -
-

Recent Calls

- {calls.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - - {calls.map((c) => ( - - ))} - -
TimeTalkgroupSystemNodeDurationAudio
-
- )} -
- - {configNode && ( - setConfigNode(null)} /> - )} -
- ); -} diff --git a/drb-frontend/app/map/page.tsx b/drb-frontend/app/map/page.tsx index 9a6831f..1eb2879 100644 --- a/drb-frontend/app/map/page.tsx +++ b/drb-frontend/app/map/page.tsx @@ -1,80 +1,7 @@ -"use client"; +import { redirect } from "next/navigation"; -import { useEffect, useState } from "react"; -import dynamic from "next/dynamic"; -import { useNodes } from "@/lib/useNodes"; -import { useActiveCalls } from "@/lib/useCalls"; -import { useActiveIncidents } from "@/lib/useIncidents"; - -const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); - -export default function MapPage() { - const { nodes, loading } = useNodes(); - const activeCalls = useActiveCalls(); - const incidents = useActiveIncidents(); - const [kiosk, setKiosk] = useState(false); - const [lastUpdated, setLastUpdated] = useState(null); - - // Track when data last refreshed - useEffect(() => { - if (!loading) setLastUpdated(new Date()); - }, [nodes, activeCalls, incidents, loading]); - - // Kiosk mode: full-viewport fixed overlay sits above the sticky nav (z-40 → z-50) - if (kiosk) { - return ( -
- - -
- ); - } - - return ( -
-
-

Map

- -
- - {loading ? ( -
- Loading map… -
- ) : ( -
- -
- )} - -
- ); +// The map is no longer a destination you navigate to — it's the product, +// and the product is the landing page. See UI_REDESIGN.md §3. +export default function MapPageRedirect() { + redirect("/"); } diff --git a/drb-frontend/app/page.tsx b/drb-frontend/app/page.tsx index db580e9..cfe35b8 100644 --- a/drb-frontend/app/page.tsx +++ b/drb-frontend/app/page.tsx @@ -1,8 +1,13 @@ +"use client"; + +import { useEffect } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { LinkButton } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { PLANS } from "@/lib/billing"; +import { useAuth } from "@/components/AuthProvider"; const CAPABILITIES = [ { @@ -37,7 +42,7 @@ const STEPS = [ { n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." }, ]; -export default function MarketingHomePage() { +function MarketingHomePage() { return (
{/* Hero */} @@ -139,3 +144,24 @@ export default function MarketingHomePage() {
); } + +/** + * "/" is marketing for a signed-out visitor and Live (the map) for anyone + * signed in — see UI_REDESIGN.md §3, "the map is the home screen". The Live + * screen itself lands in chunk 6; until then a signed-in, provisioned user + * is sent to /incidents rather than shown stale marketing copy. A user with + * no org_id yet still sees marketing (unchanged — that's the pre-redesign + * behaviour for an unprovisioned account, out of scope here). + */ +export default function HomePage() { + const { user, loading, orgId } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading || !user || !orgId) return; + router.replace("/incidents"); + }, [loading, user, orgId, router]); + + if (loading || (user && orgId)) return null; + return ; +} diff --git a/drb-frontend/components/ChromeSwitcher.tsx b/drb-frontend/components/ChromeSwitcher.tsx index 2e9ff24..c71c6dd 100644 --- a/drb-frontend/components/ChromeSwitcher.tsx +++ b/drb-frontend/components/ChromeSwitcher.tsx @@ -45,7 +45,13 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) { router.replace("/onboarding"); }, [loading, user, orgId, pathname, router]); - if (MARKETING_PATHS.has(pathname)) { + // "/" is marketing for a signed-out visitor, but the moment someone is + // signed in it's Live — the map is the home screen (UI_REDESIGN.md §3), + // not a marketing page. Every other marketing path stays marketing + // regardless of auth state. + const showMarketingChrome = MARKETING_PATHS.has(pathname) && !(pathname === "/" && user); + + if (showMarketingChrome) { return ( <> @@ -55,6 +61,17 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) { ); } + // Live ("/") is full-bleed under its own top bar, not the padded + // max-width container the rest of the app uses. + if (pathname === "/" && user) { + return ( + <> +