"use client"; import dynamic from "next/dynamic"; import { useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useIncident } from "@/lib/useIncidents"; import { useCallsByIncident } from "@/lib/useCalls"; import { useAuth } from "@/components/AuthProvider"; import { CallSpineEntry } from "@/components/CallSpineEntry"; import { c2api } from "@/lib/c2api"; import { TypeGlyph } from "@/components/marks/TypeGlyph"; import { SeverityMark } from "@/components/marks/SeverityMark"; import { isKnownSeverity } from "@/lib/severity"; import { Button } from "@/components/ui/Button"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { ErrorBanner } from "@/components/ui/EmptyState"; import { errorMessage } from "@/lib/c2error"; import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const EARLIER_PAGE_SIZE = 8; function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string { const start = new Date(startedAt).getTime(); const end = active ? Date.now() : new Date(updatedAt).getTime(); const mins = Math.max(0, Math.round((end - start) / 60000)); if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); return `${hrs}h ${mins % 60}m`; } export default function IncidentDetailPage() { const params = useParams(); const id = params.id as string; const router = useRouter(); const { incident, loading } = useIncident(id); const { calls, loading: callsLoading } = useCallsByIncident(id); const { isAdmin } = useAuth(); const [summarizing, setSummarizing] = useState(false); const [resolving, setResolving] = useState(false); const [actionError, setActionError] = useState(null); const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE); // Stop numbers and path length come from the same liveWall.pathStops() the // map's IncidentPathLayer draws from (server-26#236), so the spine numbers // exactly what the map numbers: only a movement incident (police/accident // with a pursuit tag) is numbered, and an impossible-speed stop never is. const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]); const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map()), [path]); const newestFirst = useMemo( () => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls] ); async function handleResolve() { // No reopen path exists (server-26#284), so resolving is final from the UI. if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return; setResolving(true); setActionError(null); try { await c2api.updateIncident(id, { status: "resolved" }); } catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); } finally { setResolving(false); } } async function handleSummarize() { setSummarizing(true); setActionError(null); try { await c2api.summarizeIncident(id); } catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); } finally { setSummarizing(false); } } if (loading) return

Loading…

; if (!incident) return

Incident not found.

; const displayTags = incident.tags.filter((t) => t !== "auto-generated"); const unitsActive = incident.units_active ?? incident.units ?? []; const unitsCleared = incident.units_cleared ?? []; const vehicles = incident.vehicles ?? []; const active = incident.status === "active"; const visible = newestFirst.slice(0, earlierShown); const remaining = newestFirst.length - visible.length; return (
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
{isKnownSeverity(incident.severity) && } {active ? "Active" : "Resolved"}

{incident.title ?? "Incident"}

{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed {path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path} {path?.mayBeMerged && ( {" · "}may be merged )} {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}

{isAdmin && (
{active && ( )}
)}
{actionError && } {displayTags.length > 0 && (
{displayTags.map((t) => ( {t} ))}
)} {/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
{/* Left */}
{incident.location_coords && (
)} {/* Summary — first, in prose. Not a tab. */}
{incident.summary ? (

{incident.summary}

) : (

No summary yet.{" "} {isAdmin && ( )}

)} {/* Gate A / A2 (server-26#46): the summary, the title, the location, the units and the vehicles below are ALL pipeline output, so the notice sits on this screen with them — not on a policy page. */}
{/* On scene / Cleared */}

On scene

{unitsActive.length > 0 ? (
{unitsActive.map((u) => ( {u} ))}
) : (

None extracted.

)}

Cleared

{unitsCleared.length > 0 ? (
{unitsCleared.map((u) => ( {u} ))}
) : (

None yet.

)}
{vehicles.length > 0 && (

Vehicles

{vehicles.map((v) => ( {v} ))}
)}
{/* Right — the call spine */}

Calls ({calls.length})

{/* Gate A / A2 — the spine renders transcripts. */} {calls.length > 0 && } {callsLoading ? (

Loading…

) : calls.length === 0 ? (

No calls linked yet.

) : (
{visible.map((c) => ( ))} {remaining > 0 && ( )}
)}
); }