- Incident detail "← Incidents" used router.back(), so arriving from Live or a shared link it went to the map / out of the app. Now a Link. - Resolved incidents showed "0m elapsed" (manual resolve sets no resolved_at, updated_at can predate the last call): end is now the latest of resolved_at, updated_at and the newest call. - Admin: the max-width toggle wrapped the tab bar, so it reflowed on every tab switch and left the cursor over e.g. "Close calls". Body only now. - STT Eval sat on "Loading…" forever when a scanned page had zero matches but a next_cursor — the refill effect didn't depend on cursor. - Trip event blocks used dark-only bg-indigo-950/60: unreadable in light mode. Theme tokens now. - Reassign modal said "connected for the first time" on configured nodes. - Dark-mode placeholders dimmed + italic; they read as filled-in values. Frontend unverified locally (no Node, #207); CI build typechecks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useParams } 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, endedAt: string): string {
|
|
const start = new Date(startedAt).getTime();
|
|
const end = active ? Date.now() : new Date(endedAt).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 { 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<string | null>(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<string, number>()), [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 <p className="text-ink-muted text-sm p-6">Loading…</p>;
|
|
if (!incident) return <p className="text-ink-muted text-sm p-6">Incident not found.</p>;
|
|
|
|
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";
|
|
// A manual resolve doesn't set resolved_at and updated_at can predate the
|
|
// last call, which showed "0m elapsed" on an 11-call incident — so a
|
|
// resolved incident runs to the latest of those and its newest call.
|
|
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
|
|
.filter((t): t is string => !!t)
|
|
.reduce((a, b) => (b > a ? b : a), incident.started_at);
|
|
|
|
const visible = newestFirst.slice(0, earlierShown);
|
|
const remaining = newestFirst.length - visible.length;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* A real link, not router.back(): arriving from Live or a shared URL,
|
|
"back" went to the map or out of the app. */}
|
|
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
|
|
← Incidents
|
|
</Link>
|
|
|
|
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
|
<div className="flex flex-col gap-1.5 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<TypeGlyph type={incident.type} size={20} className="text-ink-2" />
|
|
{isKnownSeverity(incident.severity) && <SeverityMark severity={incident.severity} showLabel size="md" />}
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${active ? "bg-accent/15 text-accent" : "bg-raised text-ink-2"}`}>
|
|
{active ? "Active" : "Resolved"}
|
|
</span>
|
|
</div>
|
|
<h1 className="text-[27px] font-semibold text-ink leading-tight">
|
|
{incident.title ?? "Incident"}
|
|
</h1>
|
|
<p className="text-xs text-ink-muted font-mono">
|
|
{elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
|
|
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
|
|
{path?.mayBeMerged && (
|
|
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
|
{" · "}may be merged
|
|
</span>
|
|
)}
|
|
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<div className="flex gap-2 shrink-0 flex-wrap">
|
|
<Button variant="secondary" size="sm" onClick={handleSummarize} disabled={summarizing}>
|
|
{summarizing ? "Generating…" : "Regenerate summary"}
|
|
</Button>
|
|
{active && (
|
|
<Button variant="secondary" size="sm" onClick={handleResolve} disabled={resolving}>
|
|
{resolving ? "Resolving…" : "Mark resolved"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{actionError && <ErrorBanner message={actionError} />}
|
|
|
|
{displayTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{displayTags.map((t) => (
|
|
<span key={t} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded-full">{t}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
|
{/* Left */}
|
|
<div className="lg:col-span-3 space-y-4">
|
|
{incident.location_coords && (
|
|
<div style={{ height: "352px" }} className="rounded-xl overflow-hidden border border-line">
|
|
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} calls={calls} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary — first, in prose. Not a tab. */}
|
|
<div className="space-y-2.5">
|
|
{incident.summary ? (
|
|
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
|
|
) : (
|
|
<p className="text-sm text-ink-muted italic">
|
|
No summary yet.{" "}
|
|
{isAdmin && (
|
|
<button onClick={handleSummarize} disabled={summarizing} className="text-accent not-italic hover:underline">
|
|
Generate now
|
|
</button>
|
|
)}
|
|
</p>
|
|
)}
|
|
{/* 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. */}
|
|
<MachineOutputNotice />
|
|
</div>
|
|
|
|
{/* On scene / Cleared */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">On scene</p>
|
|
{unitsActive.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{unitsActive.map((u) => (
|
|
<span key={u} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{u}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-ink-muted italic">None extracted.</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Cleared</p>
|
|
{unitsCleared.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{unitsCleared.map((u) => (
|
|
<span key={u} className="text-xs bg-transparent border border-line text-ink-muted px-2 py-0.5 rounded font-mono line-through">{u}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-ink-muted italic">None yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{vehicles.length > 0 && (
|
|
<div>
|
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{vehicles.map((v) => (
|
|
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right — the call spine */}
|
|
<div className="lg:col-span-2">
|
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
|
|
Calls ({calls.length})
|
|
</p>
|
|
{/* Gate A / A2 — the spine renders transcripts. */}
|
|
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
|
|
{callsLoading ? (
|
|
<p className="text-ink-muted text-sm">Loading…</p>
|
|
) : calls.length === 0 ? (
|
|
<p className="text-ink-muted text-sm">No calls linked yet.</p>
|
|
) : (
|
|
<div>
|
|
{visible.map((c) => (
|
|
<CallSpineEntry
|
|
key={c.call_id}
|
|
call={c}
|
|
stopNumber={stopNumberByCallId.get(c.call_id)}
|
|
isAdmin={isAdmin}
|
|
/>
|
|
))}
|
|
{remaining > 0 && (
|
|
<button
|
|
onClick={() => setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
|
|
className="text-xs text-accent hover:underline mt-2"
|
|
>
|
|
{remaining} earlier call{remaining !== 1 ? "s" : ""}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|