Make Archive a real page instead of a redirect
/calls was a ten-line stub that redirected to /incidents, so there was nowhere in the app to look at a call. The nav's "Archive" link led to the incident list, and a call that never correlated was invisible entirely -- which is backwards when correlation quality is the thing under development, because the orphans are the evidence. Its stated blocker (Gitea #17/#18) closed weeks ago. The page browses the org's calls newest-first over the new /calls/search route, filtered by link state (all / orphans / linked), transcript presence, and system, with a transcript substring search and cursor paging. A row expands to the full transcript, a playback link minted on demand, and the correlation path that decided it. The counts line -- how many of the loaded calls are orphaned, how many have no transcript at all -- is the number worth watching during an AI window. Attribution is the point of it: attach an orphan to the incident it belongs to, or detach one the correlator got wrong. Both go through the routes fixed in the previous commit, so a manual attachment now actually shows up on the incident. Admin-only. It exposes every call in the org regardless of node ownership and carries controls that rewrite incident membership. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
140dfbfc74
commit
457e6d7e0f
@@ -1,10 +1,385 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
// Destination for /calls is Archive (/search) per UI_REDESIGN.md §3/§5.4, but
|
||||
// /search needs paged server-side search (order_by/limit/cursor on
|
||||
// internal/firestore.py, a GET /calls/search route) that doesn't exist yet —
|
||||
// tracked as UI_REDESIGN.md chunk 12, blocked on Gitea #17/#18. Until then
|
||||
// this redirects to Incidents, same as the chunk 4 spec's interim.
|
||||
export default function CallsPageRedirect() {
|
||||
redirect("/incidents");
|
||||
// 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.
|
||||
//
|
||||
// Admin-only, because it exposes every call in the org regardless of node
|
||||
// ownership and carries the manual attribution controls.
|
||||
|
||||
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";
|
||||
|
||||
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,
|
||||
onChanged,
|
||||
}: {
|
||||
call: CallRecord;
|
||||
systemName?: string;
|
||||
incidents: IncidentRecord[];
|
||||
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>
|
||||
<button
|
||||
onClick={() => detach(id)}
|
||||
disabled={busy}
|
||||
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
|
||||
>
|
||||
detach
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<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 { isAdmin, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
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 && !isAdmin) router.replace("/");
|
||||
}, [authLoading, isAdmin, 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 || !isAdmin) return;
|
||||
load(null, false);
|
||||
}, [authLoading, isAdmin, 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 || !isAdmin) 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="Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,33 @@ export const c2api = {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
/**
|
||||
* Paged, filterable call archive — backs the /calls page. Distinct from
|
||||
* getCalls(), which returns every call unordered and cannot page.
|
||||
* `next_cursor` is null when the scan reached the end of the collection.
|
||||
*/
|
||||
searchCalls: (params: {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
system_id?: string;
|
||||
node_id?: string;
|
||||
talkgroup_id?: number;
|
||||
link?: "any" | "orphan" | "linked";
|
||||
transcript?: "any" | "yes" | "no";
|
||||
q?: string;
|
||||
}) => {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
return request<{
|
||||
calls: import("@/lib/types").CallRecord[];
|
||||
next_cursor: string | null;
|
||||
scanned: number;
|
||||
matched: number;
|
||||
window_exhausted: boolean;
|
||||
}>(`/calls/search?${qs.toString()}`);
|
||||
},
|
||||
patchTranscript: (callId: string, transcript: string) =>
|
||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||
@@ -85,7 +112,11 @@ export const c2api = {
|
||||
deleteIncident: (id: string) =>
|
||||
request(`/incidents/${id}`, { method: "DELETE" }),
|
||||
linkCallToIncident: (incidentId: string, callId: string) =>
|
||||
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||
request<{ ok: boolean; incident_ids: string[] }>(
|
||||
`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||
unlinkCallFromIncident: (incidentId: string, callId: string) =>
|
||||
request<{ ok: boolean; incident_emptied: boolean }>(
|
||||
`/incidents/${incidentId}/calls/${callId}`, { method: "DELETE" }),
|
||||
summarizeIncident: (id: string) =>
|
||||
request(`/incidents/${id}/summarize`, { method: "POST" }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user