Make Archive a real page instead of a redirect
Build & Deploy / Build & push images (push) Successful in 4m7s
Build & Deploy / Deploy to VM (push) Successful in 3m44s
Build & Deploy / Report a failed deploy (push) Skipped

/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:
Logan Cusano
2026-08-23 12:36:38 -04:00
co-authored by Claude Opus 5
parent 140dfbfc74
commit 457e6d7e0f
2 changed files with 415 additions and 9 deletions
+383 -8
View File
@@ -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>
);
}
+32 -1
View File
@@ -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" }),