#286: request() now throws C2ApiError — message is FastAPI's `detail` or a plain sentence for the status (401/403/404/429/5xx), with toString() so the many String(e) call sites read cleanly. status/body stay on the error; the Discord parsers rebuild the legacy "C2 API error N: body" text via legacyErrorText(), so their invite/code parsing is unchanged. #284: Resolve, Regenerate summary, alert-rule toggle/delete/load, Acknowledge, trip deletes, system delete and Restart OP25 now surface failures (ErrorBanner where the page has one, alert() where the page already uses it). Rules tab no longer says "No alert rules configured" when the load failed. Confirms added for Resolve (no reopen path exists), rule delete and trip delete. Reopen deliberately not built — manual resolve doesn't set resolved_at, so reopening is a lifecycle change. Frontend unverified: no Node on the authoring box (#207). Closes #286 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { useIncidents } from "@/lib/useIncidents";
|
|
import { useActiveCalls } from "@/lib/useCalls";
|
|
import { c2api } from "@/lib/c2api";
|
|
import { errorMessage } from "@/lib/c2error";
|
|
import type { IncidentRecord } from "@/lib/types";
|
|
import { PageHeader } from "@/components/ui/PageHeader";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
|
import { DateRange, dayStart, dayEnd } from "@/components/ui/DateRange";
|
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
|
|
|
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
|
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
|
{ key: "all", label: "All" },
|
|
{ key: "minor", label: "Minor+" },
|
|
{ key: "moderate", label: "Moderate+" },
|
|
{ key: "major", label: "Major only" },
|
|
];
|
|
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
|
|
|
type SortMode = "recent" | "severity";
|
|
type StatusFilter = "any" | "active" | "resolved";
|
|
|
|
const INCIDENT_TYPES = ["fire", "police", "ems", "accident", "other"];
|
|
|
|
// Firestore holds the paging; text/type/status filtering runs over the loaded
|
|
// window, so "Load more" also widens what the search can find.
|
|
const PAGE_SIZE = 100;
|
|
|
|
function matchesSearch(inc: IncidentRecord, needle: string): boolean {
|
|
if (!needle) return true;
|
|
const hay = [
|
|
inc.title, inc.location, inc.summary, inc.type,
|
|
...(inc.units ?? []), ...(inc.vehicles ?? []), ...(inc.tags ?? []),
|
|
...(inc.location_mentions ?? []),
|
|
].filter(Boolean).join(" ").toLowerCase();
|
|
return hay.includes(needle);
|
|
}
|
|
|
|
// The Firestore client surfaces a missing composite index or an undeployed
|
|
// ruleset as a raw multi-line string with a console URL in it — not something
|
|
// to put in front of an operator. Collapse the known infra failures to a plain
|
|
// line; pass anything else straight through so a real bug still shows.
|
|
function friendlyIncidentsError(raw: string): string {
|
|
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
|
|
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
|
|
}
|
|
return `Couldn't load incidents: ${raw}`;
|
|
}
|
|
|
|
function fmtTime(iso: string) {
|
|
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
|
}
|
|
|
|
function dayBucket(iso: string): string {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
const diffDays = Math.round((startOfDay(now) - startOfDay(d)) / 86_400_000);
|
|
if (diffDays === 0) return "Today";
|
|
if (diffDays === 1) return "Yesterday";
|
|
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
|
|
}
|
|
|
|
function timeAgo(iso: string): string {
|
|
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
return `${Math.floor(s / 86400)}d ago`;
|
|
}
|
|
|
|
// Same rail-card anatomy as Live (MapView's incident panel), at browse
|
|
// density: severity spine, type glyph, severity chip, ON AIR pill, title,
|
|
// location, units-on-scene chips, age + call count. UI_REDESIGN.md §5.1/§5.2
|
|
// — the point is that Live and Incidents read as the same object.
|
|
function IncidentBrowseRow({
|
|
incident,
|
|
isAdmin,
|
|
onAir,
|
|
onResolve,
|
|
}: {
|
|
incident: IncidentRecord;
|
|
isAdmin: boolean;
|
|
onAir: boolean;
|
|
onResolve: (id: string) => void;
|
|
}) {
|
|
const router = useRouter();
|
|
const sev = isKnownSeverity(incident.severity) ? incident.severity : "routine";
|
|
const units = incident.units_active ?? incident.units ?? [];
|
|
|
|
return (
|
|
<div
|
|
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-raised cursor-pointer transition-colors items-stretch"
|
|
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
|
>
|
|
<SeveritySpine severity={sev} />
|
|
<TypeGlyph type={incident.type} size={20} className="text-ink-2 mt-0.5 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<SeverityMark severity={sev} showLabel />
|
|
{onAir && (
|
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-sev-major/15 text-sev-major uppercase tracking-wide">
|
|
On air
|
|
</span>
|
|
)}
|
|
<Badge tone={incident.status === "active" ? "brand" : "neutral"}>{incident.status}</Badge>
|
|
</div>
|
|
<p className="text-ink text-sm font-semibold leading-snug mt-0.5 truncate">{incident.title ?? "Incident"}</p>
|
|
{incident.location && <p className="text-ink-muted text-xs mt-0.5 truncate">{incident.location}</p>}
|
|
<div className="flex items-center gap-2 flex-wrap mt-1">
|
|
{units.slice(0, 4).map((u) => (
|
|
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-raised text-ink-2">{u}</span>
|
|
))}
|
|
<span className="text-xs text-ink-muted font-mono ml-auto">
|
|
{fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{isAdmin && incident.status === "active" && (
|
|
<Button
|
|
size="sm" variant="secondary"
|
|
className="self-center shrink-0"
|
|
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
|
>
|
|
Resolve
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
|
const [title, setTitle] = useState("");
|
|
const [type, setType] = useState("other");
|
|
const [summary, setSummary] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
try {
|
|
await onCreate({ title, type, summary: summary || null, status: "active" });
|
|
onClose();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
|
<form onSubmit={handleSubmit} className="bg-surface border border-line rounded-xl p-6 w-full max-w-md space-y-4">
|
|
<h2 className="text-ink font-semibold">Create Incident</h2>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Title</label>
|
|
<input
|
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Type</label>
|
|
<select
|
|
value={type} onChange={(e) => setType(e.target.value)}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none"
|
|
>
|
|
{["fire", "police", "ems", "accident", "other"].map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-ink-muted block mb-1">Summary (optional)</label>
|
|
<textarea
|
|
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
|
|
className="w-full bg-raised border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function IncidentsPage() {
|
|
const { isAdmin } = useAuth();
|
|
const [pageLimit, setPageLimit] = useState(PAGE_SIZE);
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const rangeFrom = useMemo(() => dayStart(dateFrom), [dateFrom]);
|
|
const rangeTo = useMemo(() => dayEnd(dateTo), [dateTo]);
|
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
|
const activeCalls = useActiveCalls();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
|
const [typeFilter, setTypeFilter] = useState("");
|
|
const [search, setSearch] = useState("");
|
|
|
|
const onAirIncidentIds = useMemo(() => {
|
|
const s = new Set<string>();
|
|
for (const c of activeCalls) {
|
|
for (const id of c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []) s.add(id);
|
|
}
|
|
return s;
|
|
}, [activeCalls]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const threshold = FILTER_THRESHOLD[severityFilter];
|
|
const needle = search.trim().toLowerCase();
|
|
const list = incidents.filter((i) =>
|
|
severityRank(i.severity) >= threshold &&
|
|
(statusFilter === "any" || i.status === statusFilter) &&
|
|
(!typeFilter || i.type === typeFilter) &&
|
|
matchesSearch(i, needle)
|
|
);
|
|
if (sortMode === "severity") {
|
|
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
|
}
|
|
return list; // useIncidents() already orders by started_at desc
|
|
}, [incidents, severityFilter, sortMode, statusFilter, typeFilter, search]);
|
|
|
|
const filtersActive = severityFilter !== "all" || statusFilter !== "any" || typeFilter !== "" || search.trim() !== "" || dateFrom !== "" || dateTo !== "";
|
|
function clearFilters() {
|
|
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
|
setDateFrom(""); setDateTo(""); setPageLimit(PAGE_SIZE);
|
|
}
|
|
|
|
const hiddenCount = incidents.length - filtered.length;
|
|
const activeCount = filtered.filter((i) => i.status === "active").length;
|
|
|
|
// Timeline grouping (Today / Yesterday / date) replaces the old
|
|
// active/resolved two-table split — status is now a chip on the row, not a
|
|
// section boundary, so an active and a resolved incident from the same
|
|
// evening read as what they are: the same kind of object.
|
|
const groups = useMemo(() => {
|
|
const byDay = new Map<string, IncidentRecord[]>();
|
|
for (const inc of filtered) {
|
|
const key = dayBucket(inc.started_at);
|
|
if (!byDay.has(key)) byDay.set(key, []);
|
|
byDay.get(key)!.push(inc);
|
|
}
|
|
return byDay;
|
|
}, [filtered]);
|
|
|
|
async function handleResolve(id: string) {
|
|
// 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;
|
|
setActionError(null);
|
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Incidents"
|
|
badge={activeCount > 0 && <Badge tone="danger">{activeCount} active</Badge>}
|
|
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
|
/>
|
|
|
|
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
|
|
chips are pipeline output, so the notice rides with the list. */}
|
|
<MachineOutputNotice variant="inline" />
|
|
|
|
{actionError && <ErrorBanner message={actionError} />}
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setSeverityFilter(key)}
|
|
className={`text-sm px-3.5 py-1.5 rounded-md transition-colors ${
|
|
severityFilter === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search title, location, units…"
|
|
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-full sm:w-64 focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="any">Any status</option>
|
|
<option value="active">Active</option>
|
|
<option value="resolved">Resolved</option>
|
|
</select>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value)}
|
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-sm text-ink-2 focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="">All types</option>
|
|
{INCIDENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
<DateRange
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onChange={(f, t) => { setDateFrom(f); setDateTo(t); setPageLimit(PAGE_SIZE); }}
|
|
/>
|
|
<label className="flex items-center gap-2 text-xs text-ink-muted ml-auto">
|
|
Sort
|
|
<select
|
|
value={sortMode}
|
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
|
className="bg-surface border border-line rounded-lg px-2 py-1.5 text-ink-2 focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="recent">Most recent</option>
|
|
<option value="severity">Highest severity</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<SkeletonCard /><SkeletonCard />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{hiddenCount > 0 && (
|
|
<p className="text-xs text-ink-muted">
|
|
{hiddenCount} of {incidents.length} loaded incident{incidents.length !== 1 ? "s" : ""} hidden by filters
|
|
{hasMore && " — load more to search further back"}.
|
|
</p>
|
|
)}
|
|
|
|
{Array.from(groups.entries()).map(([day, incs]) => (
|
|
<section key={day}>
|
|
<h2 className="text-sm text-ink-muted font-medium mb-1">{day}</h2>
|
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
|
{incs.map((inc) => (
|
|
<IncidentBrowseRow
|
|
key={inc.incident_id}
|
|
incident={inc}
|
|
isAdmin={isAdmin}
|
|
onAir={onAirIncidentIds.has(inc.incident_id)}
|
|
onResolve={handleResolve}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
|
|
{/* An empty list is only news when the query actually succeeded.
|
|
A failed Firestore query (missing composite index, denied rules)
|
|
also leaves `incidents` empty, and rendering "no incidents
|
|
recorded yet" over the top of it told the operator the radio was
|
|
quiet when the page had simply failed to load — server-26#13. */}
|
|
{filtered.length === 0 && error && (
|
|
<ErrorBanner message={friendlyIncidentsError(error)} />
|
|
)}
|
|
|
|
{filtered.length === 0 && !error && (
|
|
<EmptyState
|
|
title={incidents.length === 0 && !filtersActive ? "No incidents recorded yet" : "No incidents match these filters"}
|
|
description={
|
|
incidents.length === 0 && !filtersActive
|
|
? "Incidents appear automatically once calls start correlating."
|
|
: "Try clearing a filter, or load older incidents."
|
|
}
|
|
action={
|
|
filtersActive ? (
|
|
<Button variant="secondary" size="sm" onClick={clearFilters}>Clear filters</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{hasMore && (
|
|
<div className="flex justify-center">
|
|
<Button variant="secondary" onClick={() => setPageLimit((n) => n + PAGE_SIZE)}>
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CreateModal onClose={() => setShowCreate(false)} onCreate={async (b) => { await c2api.createIncident(b); }} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|