The UI worked but read as an operator console: no public face, no way to describe or sell the thing, and no account surface beyond the node list. This adds the missing halves and reorganises what was already there around the incident, which is the unit of value the rest of the pipeline is built to produce. A shared design system replaces per-page styling: components/ui (Button, Card, Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the Tailwind config, and light-mode tokens in globals.css. The existing html:not(.dark) remap mechanism is extended rather than replaced -- a parallel theming system would have been two sources of truth for the same colours. Public marketing pages (/, /features, /pricing, /faq) load without a session. middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX redirect and is still NOT an authorisation boundary, which the comment there says explicitly. Real enforcement is unchanged and still lives server-side in c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher instead of by route group, because a route group would have collided on / and forced most of app/ to move for no behavioural gain. Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and lib/apiKeys.ts define the data model and the screens consume it, but every mutating call throws with a message naming the backend route that has to exist first, and the sample data is labelled as sample. Nothing here can charge anyone or mint a real credential -- picking a payment processor and holding its keys is a decision for a human, and a half-wired checkout is worse than an obviously absent one. The severity work from the c2-core change lands here too. severity is now a filter and sort dimension on the incident list rather than decoration, since a busy dispatch channel is only readable if you can collapse it to moderate and above. routine gets a muted treatment because it is the majority of traffic, legacy "unknown" still renders nothing, and TypeBadge handles the new "other" incident type. Severity rendering moved into lib/severity.tsx so the incident list, incident detail and call rows cannot drift apart. Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and admin. They already share the palette and stay coherent, and rewriting them would have buried the parts that actually needed to change. No colour tokens were renamed, so nothing regressed there. Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification was possible and none was done. No new environment variables.
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { useIncident } from "@/lib/useIncidents";
|
|
import { useCallsByIncident } from "@/lib/useCalls";
|
|
import { useSystems } from "@/lib/useSystems";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { CallRow } from "@/components/CallRow";
|
|
import { c2api } from "@/lib/c2api";
|
|
import type { IncidentRecord } from "@/lib/types";
|
|
import { TypeBadge } from "@/components/IncidentBadges";
|
|
import { severityBadge } from "@/lib/severity";
|
|
|
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
|
|
|
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
|
return (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
|
status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
|
}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type Tab = "summary" | "units" | "details";
|
|
|
|
export default function IncidentDetailPage() {
|
|
const params = useParams();
|
|
const id = params.id as string;
|
|
const router = useRouter();
|
|
|
|
const { incident, loading } = useIncident(id);
|
|
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
|
const { systems } = useSystems();
|
|
const { isAdmin } = useAuth();
|
|
|
|
const [tab, setTab] = useState<Tab>("summary");
|
|
const [summarizing, setSummarizing] = useState(false);
|
|
const [resolving, setResolving] = useState(false);
|
|
|
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
|
|
|
async function handleResolve() {
|
|
setResolving(true);
|
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
|
catch (e) { console.error(e); }
|
|
finally { setResolving(false); }
|
|
}
|
|
|
|
async function handleSummarize() {
|
|
setSummarizing(true);
|
|
try { await c2api.summarizeIncident(id); }
|
|
catch (e) { console.error(e); }
|
|
finally { setSummarizing(false); }
|
|
}
|
|
|
|
if (loading) return <p className="text-gray-500 text-sm font-mono p-6">Loading…</p>;
|
|
if (!incident) return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
|
|
|
|
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Back */}
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
|
>
|
|
← Incidents
|
|
</button>
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<TypeBadge type={incident.type} />
|
|
<StatusBadge status={incident.status} />
|
|
{severityBadge(incident.severity)}
|
|
</div>
|
|
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
|
{incident.title ?? "Incident"}
|
|
</h1>
|
|
</div>
|
|
{isAdmin && (
|
|
<div className="flex gap-2 shrink-0 flex-wrap">
|
|
<button
|
|
onClick={handleSummarize}
|
|
disabled={summarizing}
|
|
className="text-xs bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
{summarizing ? "Generating…" : "Regenerate summary"}
|
|
</button>
|
|
{incident.status === "active" && (
|
|
<button
|
|
onClick={handleResolve}
|
|
disabled={resolving}
|
|
className="text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-gray-300 px-3 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
{resolving ? "Resolving…" : "Resolve"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{displayTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{displayTags.map((t) => (
|
|
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Map */}
|
|
{incident.location_coords && (
|
|
<div style={{ height: "280px" }}>
|
|
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-panel body */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
|
|
|
{/* Left: tabs — Summary / Units / Details */}
|
|
<div className="lg:col-span-2 bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
|
|
{/* Tab bar */}
|
|
<div className="flex border-b border-gray-800 shrink-0">
|
|
{(["summary", "units", "details"] as Tab[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`flex-1 px-4 py-2.5 text-xs font-mono capitalize transition-colors ${
|
|
tab === t
|
|
? "text-white border-b-2 border-indigo-500 bg-gray-800/40"
|
|
: "text-gray-500 hover:text-gray-300"
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="p-4 flex-1 overflow-y-auto">
|
|
{tab === "summary" && (
|
|
incident.summary ? (
|
|
<p className="text-sm text-gray-300 leading-relaxed">{incident.summary}</p>
|
|
) : (
|
|
<p className="text-sm text-gray-600 font-mono italic">
|
|
No summary yet.{" "}
|
|
{isAdmin && (
|
|
<button
|
|
onClick={handleSummarize}
|
|
disabled={summarizing}
|
|
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
|
|
>
|
|
Generate now
|
|
</button>
|
|
)}
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
{tab === "units" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Units</p>
|
|
{incident.units?.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{incident.units.map((u) => (
|
|
<span key={u} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{u}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Vehicles</p>
|
|
{incident.vehicles?.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{incident.vehicles.map((v) => (
|
|
<span key={v} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{v}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "details" && (
|
|
<div className="space-y-3 text-xs font-mono">
|
|
{incident.location && (
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Location</p>
|
|
<p className="text-gray-300">{incident.location}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Started</p>
|
|
<p className="text-gray-300">{new Date(incident.started_at).toLocaleString()}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Last activity</p>
|
|
<p className="text-gray-300">{new Date(incident.updated_at).toLocaleString()}</p>
|
|
</div>
|
|
{incident.talkgroup_ids?.length > 0 && (
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Talkgroups</p>
|
|
<p className="text-gray-300">{incident.talkgroup_ids.join(", ")}</p>
|
|
</div>
|
|
)}
|
|
{incident.severity && (
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Severity</p>
|
|
<p className="text-gray-300 capitalize">{incident.severity}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Total calls</p>
|
|
<p className="text-gray-300">{incident.call_ids.length}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: calls */}
|
|
<div className="lg:col-span-3">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">
|
|
Calls ({calls.length})
|
|
</p>
|
|
{callsLoading ? (
|
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
|
) : calls.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
|
) : (
|
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
|
<th className="px-4 py-2 text-left">Time</th>
|
|
<th className="px-4 py-2 text-left">Talkgroup</th>
|
|
<th className="px-4 py-2 text-left hidden sm:table-cell">System</th>
|
|
<th className="px-4 py-2 text-left hidden sm:table-cell">Node</th>
|
|
<th className="px-4 py-2 text-left">Duration</th>
|
|
<th className="px-4 py-2 text-left">Audio</th>
|
|
<th className="px-4 py-2"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{calls.map((c) => (
|
|
<CallRow
|
|
key={c.call_id}
|
|
call={c}
|
|
systemName={systemMap[c.system_id ?? ""]?.name}
|
|
isAdmin={isAdmin}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|