"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 type { CallRecord } from "@/lib/types"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const EARLIER_PAGE_SIZE = 8; function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { const R = 6371; const dLat = ((b.lat - a.lat) * Math.PI) / 180; const dLng = ((b.lng - a.lng) * Math.PI) / 180; const s = Math.sin(dLat / 2) ** 2 + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } 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 [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE); // Same ordering/filtering MapView's IncidentPathLayer uses, so the stop // number shown on a spine entry matches the number on its map marker. const geocodedCalls = useMemo( () => calls .filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords) .slice() .sort((a, b) => a.started_at.localeCompare(b.started_at)), [calls] ); const stopNumberByCallId = useMemo(() => { const m = new Map(); geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1)); return m; }, [geocodedCalls]); const pathLengthKm = useMemo(() => { let total = 0; for (let i = 1; i < geocodedCalls.length; i++) { total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!); } return total; }, [geocodedCalls]); const newestFirst = useMemo( () => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls] ); async function handleResolve() { setResolving(true); try { await c2api.updateIncident(id, { status: "resolved" }); } catch (e) { console.error(e); } finally { setResolving(false); } } async function handleSummarize() { setSummarizing(true); try { await c2api.summarizeIncident(id); } catch (e) { console.error(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 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 {pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path} {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}

{isAdmin && (
{active && ( )}
)}
{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.

)}
{incident.vehicles?.length > 0 && (

Vehicles

{incident.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 && ( )}
)}
); }