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.
289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type { CallRecord } from "@/lib/types";
|
|
import { c2api } from "@/lib/c2api";
|
|
import { severityBadge } from "@/lib/severity";
|
|
|
|
interface Props {
|
|
call: CallRecord;
|
|
systemName?: string;
|
|
isAdmin?: boolean;
|
|
}
|
|
|
|
function duration(started: string, ended: string | null): string {
|
|
if (!ended) return "active";
|
|
const ms = new Date(ended).getTime() - new Date(started).getTime();
|
|
const s = Math.floor(ms / 1000);
|
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
}
|
|
|
|
const TAG_COLORS: Record<string, string> = {
|
|
fire: "bg-red-900 text-red-300",
|
|
police: "bg-blue-900 text-blue-300",
|
|
ems: "bg-yellow-900 text-yellow-300",
|
|
accident: "bg-orange-900 text-orange-300",
|
|
};
|
|
|
|
export function CallRow({ call, systemName, isAdmin }: Props) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [showOriginal, setShowOriginal] = useState(false);
|
|
const [editing, setEditing] = useState(false);
|
|
const [editText, setEditText] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
// Resolve incident links: prefer new list, fall back to legacy single field.
|
|
const incidentIds: string[] = (call.incident_ids?.length ?? 0) > 0
|
|
? call.incident_ids
|
|
: call.incident_id ? [call.incident_id] : [];
|
|
|
|
const isActive = call.status === "active";
|
|
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
|
|
// the private gs:// object location — never a playable URL. Presence of audio
|
|
// is known from the doc; the actual link is minted by the API on expand.
|
|
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
|
|
// there, so it still signals "this call has a recording".
|
|
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
|
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
|
|
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
|
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
|
const hasSegments = call.segments && call.segments.length > 1;
|
|
|
|
// Fetched lazily on expand: playback links are short-lived, so minting one
|
|
// for every row up front would waste most of them and expire the rest.
|
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
const [audioError, setAudioError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!expanded || !hasAudio || audioUrl || audioError) return;
|
|
let cancelled = false;
|
|
c2api.getCall(call.call_id)
|
|
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
|
|
.catch(() => { if (!cancelled) setAudioError(true); });
|
|
return () => { cancelled = true; };
|
|
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
|
|
|
|
function startEdit() {
|
|
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
|
setEditing(true);
|
|
}
|
|
|
|
async function saveEdit(e: React.MouseEvent) {
|
|
e.stopPropagation();
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
try {
|
|
await c2api.patchTranscript(call.call_id, editText);
|
|
setEditing(false);
|
|
} catch (err) {
|
|
setSaveError(err instanceof Error ? err.message : "Save failed");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<tr
|
|
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
|
>
|
|
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
|
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
|
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-300">
|
|
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
|
{call.tags && call.tags.length > 0 && (
|
|
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
|
{call.tags[0]}
|
|
</span>
|
|
)}
|
|
{/* Routine/minor are the majority of traffic and stay unbadged; moderate+ is the triage signal worth a badge in a dense list. */}
|
|
{(call.severity === "moderate" || call.severity === "major") && (
|
|
<span className="ml-2">{severityBadge(call.severity)}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
|
<td className="px-4 py-2">
|
|
{isActive ? (
|
|
<span className="text-orange-400 animate-pulse">● live</span>
|
|
) : (
|
|
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-xs">
|
|
{hasAudio ? (
|
|
<span className="text-blue-400">▶</span>
|
|
) : (
|
|
<span className="text-gray-700">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-600 text-xs">
|
|
{hasDetails && (expanded ? "▲" : "▼")}
|
|
</td>
|
|
</tr>
|
|
|
|
{expanded && hasDetails && (
|
|
<tr className="bg-gray-900/60 border-b border-gray-800">
|
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
|
{/* Audio player */}
|
|
{hasAudio && (
|
|
audioError ? (
|
|
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
|
|
) : audioUrl ? (
|
|
<audio
|
|
controls
|
|
src={audioUrl}
|
|
className="w-full max-w-sm h-8"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
|
)
|
|
)}
|
|
|
|
{/* Tags */}
|
|
{call.tags && call.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{call.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Incident links — one per scene detected in the recording */}
|
|
{incidentIds.length > 0 && (
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
|
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
|
{incidentIds.map((id) => (
|
|
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
|
{id.slice(0, 8)}…
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Correlation debug — admin only */}
|
|
{isAdmin && call.corr_path && (
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
|
<span>corr:</span>
|
|
<span className="text-gray-400">{call.corr_path}</span>
|
|
{call.corr_incident_idle_min != null && (
|
|
<span>idle {call.corr_incident_idle_min}min</span>
|
|
)}
|
|
{call.corr_score != null && (
|
|
<span>sim={call.corr_score.toFixed(3)}</span>
|
|
)}
|
|
{call.corr_distance_km != null && (
|
|
<span>dist={call.corr_distance_km}km</span>
|
|
)}
|
|
{call.corr_shared_units != null && (
|
|
<span>{call.corr_shared_units} shared units</span>
|
|
)}
|
|
{call.corr_candidates != null && (
|
|
<span>{call.corr_candidates} candidates</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Transcript */}
|
|
{editing ? (
|
|
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
|
<textarea
|
|
value={editText}
|
|
onChange={(e) => setEditText(e.target.value)}
|
|
rows={4}
|
|
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
|
/>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
onClick={saveEdit}
|
|
disabled={saving}
|
|
className="text-xs bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-3 py-1 rounded transition-colors"
|
|
>
|
|
{saving ? "Saving…" : "Save & reprocess"}
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
|
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
{saveError && (
|
|
<span className="text-xs text-red-400 font-mono">{saveError}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : hasSegments ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-gray-600 font-mono">
|
|
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
|
</span>
|
|
{isAdmin && (
|
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
|
)}
|
|
</div>
|
|
{hasBoth && !showOriginal ? (
|
|
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
|
{call.transcript_corrected}
|
|
</pre>
|
|
) : (
|
|
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
|
{call.segments!.map((seg, i) => (
|
|
<div key={i} className="flex gap-3 text-xs font-mono">
|
|
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span>
|
|
<span className="text-gray-300">{seg.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{hasBoth && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
|
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
|
>
|
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : displayTranscript ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
|
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>}
|
|
</div>
|
|
{isAdmin && (
|
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
|
)}
|
|
</div>
|
|
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
|
{displayTranscript}
|
|
</pre>
|
|
{hasBoth && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
|
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
|
>
|
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
}
|