Frontend redesign chunk 8: incidents browse
Rewrite app/incidents/page.tsx per UI_REDESIGN.md chunk 8. Replaces the old active/resolved two-table split with a single timeline-grouped list (Today / Yesterday / date), each row using the same rail-card anatomy as Live's incident panel — severity spine + type glyph + severity chip + ON AIR pill (from useActiveCalls, matching a call's incident_ids against the row) + title + location + on-scene unit chips + age/call-count — so status is a chip on the row instead of a section boundary, and Live/Incidents visibly read as the same object at two densities. Severity filter and sort are unchanged. The create-incident modal and resolve action are unchanged. Per UI_REDESIGN.md chunk 8.
This commit is contained in:
+115
-133
@@ -4,19 +4,17 @@ 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 { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { severityBadge, severityRank } from "@/lib/severity";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
|
||||
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
|
||||
// `severityBadge()` already returns null for the legacy "unknown" value.
|
||||
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 }[] = [
|
||||
@@ -30,120 +28,84 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
|
||||
type SortMode = "recent" | "severity";
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows / cards
|
||||
// ---------------------------------------------------------------------------
|
||||
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 IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
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 (
|
||||
<tr
|
||||
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
||||
<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}`)}
|
||||
>
|
||||
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<Card
|
||||
key={inc.incident_id}
|
||||
padding="sm"
|
||||
hover
|
||||
className="cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeBadge type={inc.type} />
|
||||
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,20 +128,20 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-bold">Create Incident</h2>
|
||||
<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-gray-400 block mb-1">Title</label>
|
||||
<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-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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-gray-400 block mb-1">Type</label>
|
||||
<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-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none"
|
||||
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>
|
||||
@@ -187,10 +149,10 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Summary (optional)</label>
|
||||
<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-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||
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">
|
||||
@@ -202,17 +164,22 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { incidents, loading } = useIncidents();
|
||||
const activeCalls = useActiveCalls();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||
|
||||
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 list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
||||
@@ -222,9 +189,22 @@ export default function IncidentsPage() {
|
||||
return list; // useIncidents() already orders by started_at desc
|
||||
}, [incidents, severityFilter, sortMode]);
|
||||
|
||||
const active = filtered.filter((i) => i.status === "active");
|
||||
const resolved = filtered.filter((i) => i.status === "resolved");
|
||||
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" }); }
|
||||
@@ -235,31 +215,30 @@ export default function IncidentsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Incidents"
|
||||
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
|
||||
badge={activeCount > 0 && <Badge tone="danger">{activeCount} active</Badge>}
|
||||
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||
/>
|
||||
|
||||
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||
<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 font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
||||
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
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>
|
||||
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
|
||||
<label className="flex items-center gap-2 text-xs text-ink-muted">
|
||||
Sort
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
@@ -274,24 +253,27 @@ export default function IncidentsPage() {
|
||||
) : (
|
||||
<>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
<p className="text-xs text-ink-muted">
|
||||
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||
<IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
{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>
|
||||
)}
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
|
||||
<IncidentTable incidents={resolved} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
</section>
|
||||
)}
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<EmptyState
|
||||
|
||||
Reference in New Issue
Block a user