Files
server-26/drb-frontend/components/CallRow.tsx
T
2026-04-23 01:26:41 -04:00

234 lines
9.9 KiB
TypeScript

"use client";
import { useState } from "react";
import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
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";
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
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;
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">
{new Date(call.started_at).toLocaleTimeString()}
</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>
)}
</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">
{call.audio_url ? (
<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 */}
{call.audio_url && (
<audio
controls
src={call.audio_url}
className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()}
/>
)}
{/* 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>
)}
{/* 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>
)}
</>
);
}