Frontend redesign chunk 7: incident detail rebuild
Rewrite app/incidents/[id]/page.tsx to UI_REDESIGN.md §5.2. Header is now type glyph + SeverityMark + active/resolved chip + a 27px title, with elapsed time, path length (haversine sum over geocoded calls) and call count as a single subline. Summary is promoted out of the old tab into a first-class prose block (16.5px/1.58) — it's the artifact the product sells, so it gets the best position instead of competing with Units/ Details behind a click. Units/Details tabs are gone; On scene / Cleared render directly from units_active/units_cleared (chunk 3), Vehicles below. New components/CallSpineEntry.tsx replaces CallRow for this page (CallRow stays for the Archive table until chunk 12): time-ordered entries with a numbered stop marker that matches the map's path stops via the same sort-by-started_at-over-geocoded-calls index MapView's IncidentPathLayer uses — the "shared index" from §2.4. Includes an inline play/scrub audio player (lazy-fetches the signed URL on first play, same pattern CallRow already used), transcript in sans prose instead of a font-mono <pre>, unit/ cleared-unit chips, and a paginating "N earlier calls" control. Thin/ status-only calls collapse to one line. The incident map keeps the location_coords guard and now passes `calls` through to MapView so its path polyline (chunk 5) renders here too. Per UI_REDESIGN.md chunk 7.
This commit is contained in:
@@ -1,31 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo, useState } from "react";
|
||||
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 { CallSpineEntry } from "@/components/CallSpineEntry";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
import { severityBadge } from "@/lib/severity";
|
||||
import { TypeGlyph } from "@/components/marks/TypeGlyph";
|
||||
import { SeverityMark } from "@/components/marks/SeverityMark";
|
||||
import { isKnownSeverity } from "@/lib/severity";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
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>
|
||||
);
|
||||
const EARLIER_PAGE_SIZE = 8;
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371;
|
||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
||||
}
|
||||
|
||||
type Tab = "summary" | "units" | "details";
|
||||
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
||||
const mins = Math.max(0, Math.round((end - start) / 60000));
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
return `${hrs}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
export default function IncidentDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -34,14 +44,40 @@ export default function IncidentDetailPage() {
|
||||
|
||||
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 [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
|
||||
// number shown on a spine entry matches the number on its map marker.
|
||||
const geocodedCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
|
||||
[calls]
|
||||
);
|
||||
const stopNumberByCallId = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
|
||||
return m;
|
||||
}, [geocodedCalls]);
|
||||
|
||||
const pathLengthKm = useMemo(() => {
|
||||
let total = 0;
|
||||
for (let i = 1; i < geocodedCalls.length; i++) {
|
||||
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
|
||||
}
|
||||
return total;
|
||||
}, [geocodedCalls]);
|
||||
|
||||
const newestFirst = useMemo(
|
||||
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
||||
[calls]
|
||||
);
|
||||
|
||||
async function handleResolve() {
|
||||
setResolving(true);
|
||||
@@ -57,220 +93,163 @@ export default function IncidentDetailPage() {
|
||||
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>;
|
||||
if (loading) return <p className="text-ink-muted text-sm p-6">Loading…</p>;
|
||||
if (!incident) return <p className="text-ink-muted text-sm p-6">Incident not found.</p>;
|
||||
|
||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||
const unitsCleared = incident.units_cleared ?? [];
|
||||
const active = incident.status === "active";
|
||||
|
||||
const visible = newestFirst.slice(0, earlierShown);
|
||||
const remaining = newestFirst.length - visible.length;
|
||||
|
||||
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"
|
||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
||||
>
|
||||
← Incidents
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
||||
<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 flex-col gap-1.5 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
<StatusBadge status={incident.status} />
|
||||
{severityBadge(incident.severity)}
|
||||
<TypeGlyph type={incident.type} size={20} className="text-ink-2" />
|
||||
{isKnownSeverity(incident.severity) && <SeverityMark severity={incident.severity} showLabel size="md" />}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${active ? "bg-accent/15 text-accent" : "bg-raised text-ink-2"}`}>
|
||||
{active ? "Active" : "Resolved"}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||
<h1 className="text-[27px] font-semibold text-ink leading-tight">
|
||||
{incident.title ?? "Incident"}
|
||||
</h1>
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
||||
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>}
|
||||
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={handleSummarize} disabled={summarizing}>
|
||||
{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>
|
||||
</Button>
|
||||
{active && (
|
||||
<Button variant="secondary" size="sm" onClick={handleResolve} disabled={resolving}>
|
||||
{resolving ? "Resolving…" : "Mark resolved"}
|
||||
</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>
|
||||
<span key={t} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded-full">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
||||
{/* Left */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{incident.location_coords && (
|
||||
<div style={{ height: "280px" }}>
|
||||
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} />
|
||||
<div style={{ height: "352px" }} className="rounded-xl overflow-hidden border border-line">
|
||||
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} calls={calls} />
|
||||
</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>
|
||||
{/* Summary — first, in prose. Not a tab. */}
|
||||
<div>
|
||||
{incident.summary ? (
|
||||
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 font-mono italic">
|
||||
<p className="text-sm text-ink-muted italic">
|
||||
No summary yet.{" "}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={handleSummarize}
|
||||
disabled={summarizing}
|
||||
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
|
||||
>
|
||||
<button onClick={handleSummarize} disabled={summarizing} className="text-accent not-italic hover:underline">
|
||||
Generate now
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "units" && (
|
||||
<div className="space-y-4">
|
||||
{/* On scene / Cleared */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Units</p>
|
||||
{incident.units?.length > 0 ? (
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">On scene</p>
|
||||
{unitsActive.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>
|
||||
{unitsActive.map((u) => (
|
||||
<span key={u} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
|
||||
<p className="text-xs text-ink-muted 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 ? (
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Cleared</p>
|
||||
{unitsCleared.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{unitsCleared.map((u) => (
|
||||
<span key={u} className="text-xs bg-transparent border border-line text-ink-muted px-2 py-0.5 rounded font-mono line-through">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-muted italic">None yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{incident.vehicles?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||
<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>
|
||||
<span key={v} className="text-xs bg-raised text-ink-2 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">
|
||||
{/* Right — the call spine */}
|
||||
<div className="lg:col-span-2">
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
|
||||
Calls ({calls.length})
|
||||
</p>
|
||||
{callsLoading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm">Loading…</p>
|
||||
) : calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
||||
<p className="text-ink-muted text-sm">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
|
||||
<div>
|
||||
{visible.map((c) => (
|
||||
<CallSpineEntry
|
||||
key={c.call_id}
|
||||
call={c}
|
||||
systemName={systemMap[c.system_id ?? ""]?.name}
|
||||
stopNumber={stopNumberByCallId.get(c.call_id)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{remaining > 0 && (
|
||||
<button
|
||||
onClick={() => setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{remaining} earlier call{remaining !== 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One entry in an incident's call spine — UI_REDESIGN.md §5.2. Replaces
|
||||
* CallRow for this context (CallRow stays in use for the Archive table
|
||||
* until chunk 12). `stopNumber` is the same index the map's path stops use
|
||||
* (see IncidentPathLayer in MapView.tsx) — that shared index is the one
|
||||
* memorable thing per §2.4: "where was he when he said that" is answered
|
||||
* by looking, not cross-referencing timestamps.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = Math.floor(s % 60);
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
if (!hasAudio) return null;
|
||||
|
||||
async function ensureUrl() {
|
||||
if (url || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const full = await c2api.getCall(callId);
|
||||
setUrl(full.audio_url ?? null);
|
||||
if (!full.audio_url) setError(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (!url) {
|
||||
await ensureUrl();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) el.pause();
|
||||
else el.play();
|
||||
}
|
||||
|
||||
// Once the URL lands, autoplay (the click that fetched it was the play intent).
|
||||
useEffect(() => {
|
||||
if (url && audioRef.current) audioRef.current.play().catch(() => {});
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return <span className="text-xs text-sev-major">Audio unavailable</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
||||
title={playing ? "Pause" : "Play"}
|
||||
>
|
||||
{loading ? "…" : playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={current}
|
||||
onChange={(e) => {
|
||||
const t = Number(e.target.value);
|
||||
if (audioRef.current) audioRef.current.currentTime = t;
|
||||
setCurrent(t);
|
||||
}}
|
||||
className="flex-1 h-1 accent-accent"
|
||||
disabled={!url}
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||
{fmtClock(current)}
|
||||
</span>
|
||||
{url && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
className="hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
||||
if (!stopNumber) {
|
||||
return <span className="w-6 h-6 shrink-0 rounded-full border border-line-strong" />;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[11px] font-semibold text-page"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{stopNumber}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallSpineEntry({
|
||||
call,
|
||||
stopNumber,
|
||||
isAdmin,
|
||||
}: {
|
||||
call: CallRecord;
|
||||
stopNumber?: number;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||
const transcript = call.transcript_corrected || call.transcript;
|
||||
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
||||
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
||||
|
||||
const units = call.units ?? [];
|
||||
const clearedInCall = call.cleared_units ?? [];
|
||||
|
||||
if (!substantive) {
|
||||
// Thin/routine calls collapse to a single line.
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5 text-xs text-ink-muted">
|
||||
<span className="w-6 shrink-0" />
|
||||
<span className="font-mono tabular-nums shrink-0">{fmtTime(call.started_at)}</span>
|
||||
<span className="truncate">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} · status only</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 py-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<StopMarker stopNumber={stopNumber} color={color} />
|
||||
<span className="flex-1 w-px bg-line mt-1" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-ink-muted">
|
||||
<span className="font-mono tabular-nums">{fmtTime(call.started_at)}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"}</span>
|
||||
<span className="font-mono">{call.node_id}</span>
|
||||
</div>
|
||||
|
||||
{hasAudio && (
|
||||
<div className="mt-1.5">
|
||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcript && (
|
||||
<p
|
||||
className={`text-sm text-ink leading-relaxed mt-1.5 ${!expanded ? "line-clamp-2" : ""}`}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{transcript}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
{call.location && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2">{call.location}</span>
|
||||
)}
|
||||
{units.map((u) => (
|
||||
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2 font-mono">{u}</span>
|
||||
))}
|
||||
{clearedInCall.map((u) => (
|
||||
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-transparent border border-line text-ink-muted font-mono line-through">{u}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin && call.corr_path && (
|
||||
<p className="text-[10px] font-mono text-ink-muted mt-1">corr: {call.corr_path}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user