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:
+126
-144
@@ -4,149 +4,111 @@ import { useMemo, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { Card } from "@/components/ui/Card";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { severityBadge, severityRank } from "@/lib/severity";
|
import { isKnownSeverity, severityRank } from "@/lib/severity";
|
||||||
import { TypeBadge } from "@/components/IncidentBadges";
|
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
|
||||||
|
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||||
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
|
|
||||||
// `severityBadge()` already returns null for the legacy "unknown" value.
|
|
||||||
|
|
||||||
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
||||||
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
||||||
{ key: "all", label: "All" },
|
{ key: "all", label: "All" },
|
||||||
{ key: "minor", label: "Minor+" },
|
{ key: "minor", label: "Minor+" },
|
||||||
{ key: "moderate", label: "Moderate+" },
|
{ key: "moderate", label: "Moderate+" },
|
||||||
{ key: "major", label: "Major only" },
|
{ key: "major", label: "Major only" },
|
||||||
];
|
];
|
||||||
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
||||||
|
|
||||||
type SortMode = "recent" | "severity";
|
type SortMode = "recent" | "severity";
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function dayBucket(iso: string): string {
|
||||||
// Rows / cards
|
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;
|
incident: IncidentRecord;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
onAir: boolean;
|
||||||
onResolve: (id: string) => void;
|
onResolve: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const sev = isKnownSeverity(incident.severity) ? incident.severity : "routine";
|
||||||
|
const units = incident.units_active ?? incident.units ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
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}`)}
|
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
<SeveritySpine severity={sev} />
|
||||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
<TypeGlyph type={incident.type} size={20} className="text-ink-2 mt-0.5 shrink-0" />
|
||||||
<td className="px-4 py-3">
|
<div className="min-w-0 flex-1">
|
||||||
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
</td>
|
<SeverityMark severity={sev} showLabel />
|
||||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
{onAir && (
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-sev-major/15 text-sev-major uppercase tracking-wide">
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td>
|
On air
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
</span>
|
||||||
<td className="px-4 py-3">
|
)}
|
||||||
{isAdmin && incident.status === "active" && (
|
<Badge tone={incident.status === "active" ? "brand" : "neutral"}>{incident.status}</Badge>
|
||||||
<Button
|
</div>
|
||||||
size="sm" variant="secondary"
|
<p className="text-ink text-sm font-semibold leading-snug mt-0.5 truncate">{incident.title ?? "Incident"}</p>
|
||||||
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
{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">
|
||||||
Resolve
|
{units.slice(0, 4).map((u) => (
|
||||||
</Button>
|
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-raised text-ink-2">{u}</span>
|
||||||
)}
|
))}
|
||||||
</td>
|
<span className="text-xs text-ink-muted font-mono ml-auto">
|
||||||
</tr>
|
{fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||||
);
|
</span>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
{isAdmin && incident.status === "active" && (
|
||||||
incidents: IncidentRecord[];
|
<Button
|
||||||
isAdmin: boolean;
|
size="sm" variant="secondary"
|
||||||
onResolve: (id: string) => void;
|
className="self-center shrink-0"
|
||||||
}) {
|
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||||
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">
|
Resolve
|
||||||
<div className="flex items-center gap-2">
|
</Button>
|
||||||
<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>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [type, setType] = useState("other");
|
const [type, setType] = useState("other");
|
||||||
@@ -166,20 +128,20 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<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">
|
<form onSubmit={handleSubmit} className="bg-surface border border-line rounded-xl p-6 w-full max-w-md space-y-4">
|
||||||
<h2 className="text-white font-bold">Create Incident</h2>
|
<h2 className="text-ink font-semibold">Create Incident</h2>
|
||||||
<div>
|
<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
|
<input
|
||||||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
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>
|
||||||
<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
|
<select
|
||||||
value={type} onChange={(e) => setType(e.target.value)}
|
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) => (
|
{["fire", "police", "ems", "accident", "other"].map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
@@ -187,10 +149,10 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<textarea
|
||||||
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
|
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>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
@@ -202,17 +164,22 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Page
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { incidents, loading } = useIncidents();
|
const { incidents, loading } = useIncidents();
|
||||||
|
const activeCalls = useActiveCalls();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
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 filtered = useMemo(() => {
|
||||||
const threshold = FILTER_THRESHOLD[severityFilter];
|
const threshold = FILTER_THRESHOLD[severityFilter];
|
||||||
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
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
|
return list; // useIncidents() already orders by started_at desc
|
||||||
}, [incidents, severityFilter, sortMode]);
|
}, [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 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) {
|
async function handleResolve(id: string) {
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
@@ -235,31 +215,30 @@ export default function IncidentsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Incidents"
|
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>}
|
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 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 }) => (
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setSeverityFilter(key)}
|
onClick={() => setSeverityFilter(key)}
|
||||||
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
className={`text-sm px-3.5 py-1.5 rounded-md transition-colors ${
|
||||||
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
severityFilter === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
Sort
|
||||||
<select
|
<select
|
||||||
value={sortMode}
|
value={sortMode}
|
||||||
onChange={(e) => setSortMode(e.target.value as 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="recent">Most recent</option>
|
||||||
<option value="severity">Highest severity</option>
|
<option value="severity">Highest severity</option>
|
||||||
@@ -274,24 +253,27 @@ export default function IncidentsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{hiddenCount > 0 && (
|
{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.
|
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active.length > 0 && (
|
{Array.from(groups.entries()).map(([day, incs]) => (
|
||||||
<section>
|
<section key={day}>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
<h2 className="text-sm text-ink-muted font-medium mb-1">{day}</h2>
|
||||||
<IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
|
<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>
|
</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 && (
|
{filtered.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
Reference in New Issue
Block a user