Files
server-26/drb-frontend/app/calls/page.tsx
T
Logan CusanoandClaude Opus 5.5 fa194e0f0a frontend: search, filters and load-more on Incidents; open Archive to viewers
Incidents page: text search (title, location, summary, units, vehicles,
tags, location mentions), status and type filters, and a Load more button
that pages the Firestore query 100 at a time. Filtering runs over the
loaded window, and the page says so when older incidents exist.

Archive (/calls): readable by every org member, not just admins.
GET /calls/search now takes any Firebase token scoped to the caller's org
— the Firestore rules already let members read every call in their org,
so this widens nothing. Attach/detach stays admin-only (UI and routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:53:46 -04:00

399 lines
15 KiB
TypeScript

"use client";
// Archive — the call-level view. Until now /calls was a ten-line stub that
// redirected to /incidents, so there was no way to look at a call anywhere in
// the app: the nav's "Archive" link led to the incident list, and a call that
// never correlated was invisible. That is the wrong way round when correlation
// quality is the thing under development — the orphans are the evidence.
//
// Readable by every org member — the Firestore rules already let any member
// read every call in their org. The manual attribution controls stay
// admin-only, matching the admin gate on the link/unlink routes.
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useSystems } from "@/lib/useSystems";
import { useIncidents } from "@/lib/useIncidents";
import { c2api } from "@/lib/c2api";
import type { CallRecord, IncidentRecord } from "@/lib/types";
import { PageHeader } from "@/components/ui/PageHeader";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
type LinkFilter = "any" | "orphan" | "linked";
type TranscriptFilter = "any" | "yes" | "no";
const LINK_FILTERS: { key: LinkFilter; label: string }[] = [
{ key: "any", label: "All" },
{ key: "orphan", label: "Orphans" },
{ key: "linked", label: "Linked" },
];
const TRANSCRIPT_FILTERS: { key: TranscriptFilter; label: string }[] = [
{ key: "any", label: "Any" },
{ key: "yes", label: "Transcribed" },
{ key: "no", label: "No transcript" },
];
const PAGE_SIZE = 50;
function fmtWhen(iso?: string | null): string {
if (!iso) return "—";
try {
const d = new Date(iso);
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
} catch {
return String(iso);
}
}
function fmtDuration(call: CallRecord): string {
if (!call.ended_at) return "active";
const ms = new Date(call.ended_at).getTime() - new Date(call.started_at).getTime();
const s = Math.max(0, Math.round(ms / 1000));
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}`;
}
function callIncidentIds(call: CallRecord): string[] {
if (call.incident_ids?.length) return call.incident_ids;
return call.incident_id ? [call.incident_id] : [];
}
/** One archive row: metadata, transcript, audio, and the attribution control. */
function ArchiveRow({
call,
systemName,
incidents,
canEdit,
onChanged,
}: {
call: CallRecord;
systemName?: string;
incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachTo, setAttachTo] = useState("");
const linkedIds = callIncidentIds(call);
const text = call.transcript_corrected || call.transcript || "";
// The stored document holds only the private gs:// object location; a
// playable link is minted per read by the API, so fetch it on expand.
useEffect(() => {
if (!open || audioUrl) return;
let cancelled = false;
c2api
.getCall(call.call_id)
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
.catch(() => { /* audio is optional — the row is still useful without it */ });
return () => { cancelled = true; };
}, [open, audioUrl, call.call_id]);
async function attach() {
if (!attachTo) return;
setBusy(true); setError(null);
try {
await c2api.linkCallToIncident(attachTo, call.call_id);
setAttachTo("");
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function detach(incidentId: string) {
setBusy(true); setError(null);
try {
await c2api.unlinkCallFromIncident(incidentId, call.call_id);
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<Card padding="none" className="overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-raised/40 transition-colors"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-ink font-mono text-xs shrink-0">{fmtWhen(call.started_at)}</span>
<span className="text-ink-2 text-sm font-medium truncate">
{call.talkgroup_name || (call.talkgroup_id ? `TGID ${call.talkgroup_id}` : "unknown talkgroup")}
</span>
<span className="text-ink-muted text-xs font-mono">{fmtDuration(call)}</span>
{linkedIds.length === 0 ? (
<Badge tone="warning">orphan</Badge>
) : (
<Badge tone="neutral">{linkedIds.length === 1 ? "linked" : `${linkedIds.length} incidents`}</Badge>
)}
{!text && <Badge tone="danger">no transcript</Badge>}
{systemName && <span className="text-ink-muted text-xs ml-auto shrink-0">{systemName}</span>}
</div>
{text && !open && (
<p className="text-ink-muted text-xs mt-1.5 truncate">{text}</p>
)}
</button>
{open && (
<div className="px-4 pb-4 space-y-3 border-t border-line pt-3">
{text ? (
<p className="text-ink-2 text-sm leading-relaxed">{text}</p>
) : (
<p className="text-ink-muted text-xs italic">
No transcript. Either STT was off when this call landed, or Whisper rejected it as
silence or degenerate output.
</p>
)}
{audioUrl && (
/* eslint-disable-next-line jsx-a11y/media-has-caption */
<audio controls src={audioUrl} className="w-full h-9" />
)}
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs">
<div><dt className="text-ink-muted inline">call </dt><dd className="text-ink-2 font-mono inline">{call.call_id.slice(0, 8)}</dd></div>
<div><dt className="text-ink-muted inline">node </dt><dd className="text-ink-2 font-mono inline">{call.node_id ?? "—"}</dd></div>
<div><dt className="text-ink-muted inline">tgid </dt><dd className="text-ink-2 font-mono inline">{call.talkgroup_id ?? "—"}</dd></div>
<div><dt className="text-ink-muted inline">path </dt><dd className="text-ink-2 font-mono inline">{call.corr_path ?? "—"}</dd></div>
</dl>
{/* Manual attribution */}
<div className="space-y-2">
{linkedIds.map((id) => {
const inc = incidents.find((i) => i.incident_id === id);
return (
<div key={id} className="flex items-center gap-2 text-xs">
<span className="text-ink-muted">attached to</span>
<span className="text-ink-2 truncate">{inc?.title ?? id.slice(0, 8)}</span>
{canEdit && <button
onClick={() => detach(id)}
disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
>
detach
</button>}
</div>
);
})}
{canEdit && <div className="flex flex-wrap items-center gap-2">
<select
value={attachTo}
onChange={(e) => setAttachTo(e.target.value)}
className="bg-surface border border-line rounded-md text-xs text-ink px-2 py-1.5 max-w-xs"
>
<option value="">Attach to incident…</option>
{incidents
.filter((i) => !linkedIds.includes(i.incident_id))
.slice(0, 100)
.map((i) => (
<option key={i.incident_id} value={i.incident_id}>
{fmtWhen(i.started_at)} — {i.title}
</option>
))}
</select>
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"}
</Button>
</div>}
</div>
{error && <ErrorBanner message={error} />}
</div>
)}
</Card>
);
}
export default function ArchivePage() {
const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems();
const { incidents } = useIncidents(200);
const [calls, setCalls] = useState<CallRecord[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [moreAvailable, setMoreAvailable] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<LinkFilter>("any");
const [transcript, setTranscript] = useState<TranscriptFilter>("any");
const [systemId, setSystemId] = useState("");
const [q, setQ] = useState("");
const [submittedQ, setSubmittedQ] = useState("");
useEffect(() => {
if (!authLoading && !canView) router.replace("/");
}, [authLoading, canView, router]);
const load = useCallback(
async (nextCursor: string | null, append: boolean) => {
setLoading(true);
setError(null);
try {
const res = await c2api.searchCalls({
limit: PAGE_SIZE,
cursor: nextCursor,
link,
transcript,
system_id: systemId || undefined,
q: submittedQ || undefined,
});
setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls));
setCursor(res.next_cursor);
setMoreAvailable(Boolean(res.next_cursor));
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
},
[link, transcript, systemId, submittedQ],
);
// Reload from the top whenever a filter changes.
useEffect(() => {
if (authLoading || !canView) return;
load(null, false);
}, [authLoading, canView, load]);
const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name]));
return (id?: string | null) => (id ? m.get(id) : undefined);
}, [systems]);
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
if (authLoading || !canView) return null;
const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length;
const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length;
return (
<div className="space-y-6">
<PageHeader
title="Archive"
description={isAdmin
? "Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
: "Every call on the account, correlated or not."}
/>
<div className="flex flex-wrap items-center gap-3">
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1">
{LINK_FILTERS.map(({ key, label }) => (
<button
key={key}
onClick={() => setLink(key)}
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
link === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
}`}
>
{label}
</button>
))}
</div>
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1">
{TRANSCRIPT_FILTERS.map(({ key, label }) => (
<button
key={key}
onClick={() => setTranscript(key)}
className={`text-sm px-3 py-1.5 rounded-md transition-colors ${
transcript === key ? "bg-raised text-ink" : "text-ink-muted hover:text-ink-2"
}`}
>
{label}
</button>
))}
</div>
<select
value={systemId}
onChange={(e) => setSystemId(e.target.value)}
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2"
>
<option value="">All systems</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name}</option>
))}
</select>
<form
onSubmit={(e) => { e.preventDefault(); setSubmittedQ(q.trim()); }}
className="flex items-center gap-2 ml-auto"
>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search transcripts…"
className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-56"
/>
<Button size="sm" variant="secondary" type="submit">Search</Button>
</form>
</div>
{calls.length > 0 && (
<p className="text-ink-muted text-xs font-mono">
{calls.length} calls · {orphanCount} orphaned · {noTranscript} without a transcript
</p>
)}
{/* Gate A / A2 (server-26#46) — every row expands to a transcript. */}
<MachineOutputNotice
detail="transcripts and the incident links derived from them are automated output and may contain errors, including misheard names, addresses and unit numbers. Check the recording before acting on them."
/>
{error && <ErrorBanner message={`Couldn't load calls: ${error}`} />}
{loading && calls.length === 0 ? (
<div className="space-y-2">
<SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
) : calls.length === 0 && !error ? (
<EmptyState
title="No calls match these filters"
description="The search scans a bounded window of the most recent calls — widen the filters or clear the search text."
/>
) : (
<div className="space-y-2">
{calls.map((call) => (
<ArchiveRow
key={call.call_id}
call={call}
systemName={systemName(call.system_id)}
incidents={incidents}
canEdit={isAdmin}
onChanged={() => load(null, false)}
/>
))}
</div>
)}
{moreAvailable && (
<div className="flex justify-center">
<Button variant="secondary" onClick={() => load(cursor, true)} disabled={loading}>
{loading ? "Loading…" : "Load more"}
</Button>
</div>
)}
</div>
);
}