Incidents page: text search (title, location, summary, units, vehicles, tags, location mentions), status and type filters, and a Load more button that pages the Firestore query 100 at a time. Filtering runs over the loaded window, and the page says so when older incidents exist. Archive (/calls): readable by every org member, not just admins. GET /calls/search now takes any Firebase token scoped to the caller's org — the Firestore rules already let members read every call in their org, so this widens nothing. Attach/detach stays admin-only (UI and routes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
16 KiB
TypeScript
395 lines
16 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 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 { 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 { incidents, loading, error, hasMore } = useIncidents(pageLimit);
|
|
const activeCalls = useActiveCalls();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
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() !== "";
|
|
function clearFilters() {
|
|
setSeverityFilter("all"); setStatusFilter("any"); setTypeFilter(""); setSearch("");
|
|
}
|
|
|
|
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) {
|
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
|
catch (e) { console.error(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" />
|
|
|
|
<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>
|
|
<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 ? "No incidents recorded yet" : "No incidents match this filter"}
|
|
description={
|
|
incidents.length === 0
|
|
? "Incidents appear automatically once calls start correlating."
|
|
: "Try clearing a filter, or load older incidents."
|
|
}
|
|
action={
|
|
incidents.length > 0 && 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>
|
|
);
|
|
}
|