225 lines
9.4 KiB
TypeScript
225 lines
9.4 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);
|
|
const isActive = call.status === "active";
|
|
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id;
|
|
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">
|
|
{call.audio_url ? (
|
|
<a
|
|
href={call.audio_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-blue-400 hover:text-blue-300 text-xs"
|
|
>
|
|
audio
|
|
</a>
|
|
) : (
|
|
<span className="text-gray-700 text-xs">—</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">
|
|
{/* 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 link */}
|
|
{call.incident_id && (
|
|
<p className="text-xs font-mono text-indigo-400">
|
|
Incident:{" "}
|
|
<a href={`/incidents/${call.incident_id}`} className="underline hover:text-indigo-300">
|
|
{call.incident_id.slice(0, 8)}…
|
|
</a>
|
|
</p>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|