diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index 2ea5fca..f041488 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -4,149 +4,111 @@ 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 }[] = [ - { key: "all", label: "All" }, - { key: "minor", label: "Minor+" }, + { key: "all", label: "All" }, + { key: "minor", label: "Minor+" }, { key: "moderate", label: "Moderate+" }, - { key: "major", label: "Major only" }, + { key: "major", label: "Major only" }, ]; const FILTER_THRESHOLD: Record = { all: -1, minor: 1, moderate: 2, major: 3 }; 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 ( - router.push(`/incidents/${incident.incident_id}`)} > - - {incident.title ?? "—"} - - {incident.status} - - {severityBadge(incident.severity)} - {incident.call_ids.length} - {fmtTime(incident.started_at)} - {fmtTime(incident.updated_at)} - - {isAdmin && incident.status === "active" && ( - - )} - - - ); -} - -function IncidentCards({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - const router = useRouter(); - return ( -
- {incidents.map((inc) => ( - router.push(`/incidents/${inc.incident_id}`)} + + +
+
+ + {onAir && ( + + On air + + )} + {incident.status} +
+

{incident.title ?? "Incident"}

+ {incident.location &&

{incident.location}

} +
+ {units.slice(0, 4).map((u) => ( + {u} + ))} + + {fmtTime(incident.started_at)} · {timeAgo(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} + +
+
+ {isAdmin && incident.status === "active" && ( + - )} -
-

{inc.title ?? "—"}

-
- {severityBadge(inc.severity)} -

- {fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""} -

-
- - ))} + Resolve + + )} ); } -function IncidentTable({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - return ( - <> -
- -
-
- - - - - - - - - - - - - - - {incidents.map((inc) => ( - - ))} - -
TypeTitleStatusSeverityCallsStartedUpdated
-
- - ); -} - function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise }) { const [title, setTitle] = useState(""); const [type, setType] = useState("other"); @@ -166,20 +128,20 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo return (
-
-

Create Incident

+ +

Create Incident

- + 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" />
- +
- +