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
+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" }),