Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79 decision 14) blocks putting a price or an unbuilt entitlement claim on a surface a reader can see, and requires that unverified machine assertions be labelled as such on the same screen as the assertion. The pricing leg was already met — /pricing and both homepage CTAs stopped quoting the invented catalog. Condition A2 was not: a search of the whole frontend for a "machine-generated" or "unverified" qualifier returned zero hits. Every transcript, summary, title, location, unit list and vehicle list is pipeline output that no human reviews, and entity-name accuracy in those transcripts has never been measured (server-26#48) — yet all of it was rendered to the reader as plain fact. Unqualified machine assertions about real incidents and real people is the exposure Gate A exists to stop. A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on the same screen as the output (a footnote elsewhere does not satisfy A1's "same screen" standard). Three variants for three shapes of surface, all saying the same thing; the "popup" variant uses fixed grays because a Leaflet popup is stock-white in both themes. Covered: - incident detail: under the summary (covers summary, title, location, units on scene/cleared, vehicles, tags) and above the call spine - incident list: above the timeline groups - Archive (/calls): above the transcript rows - node detail: above the Recent Calls table - Watch//alerts: above the events table, whose Snippet column is transcript text and whose keyword match was made against it - Live map: the desktop incident rail, pinned above the scroll area so it cannot be scrolled off the screen it qualifies; the mobile drawer; the incident marker popup; the incident-path stop popup - /systems: the source-call transcript preview - /features: the two marketing sections that describe the AI pipeline A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline: - /faq: the retention answer promised 7/90/365-day windows. There is no TTL and no deletion sweep anywhere in the product (server-26#44), so the answer now states plainly that nothing is deleted automatically and marks per-plan retention as not yet available. - /settings/billing: the plan cards' claims — custom retention, SSO/SAML, uptime SLA, data residency — are marked not-yet-available next to the plan that makes them. Labelling only. No retention, SSO, SLA or residency was built; no billing, Stripe or checkout code was touched (Gate B still bars charging anyone); no price was added anywhere; no Python was touched. Both themes verified against the light-mode !important overrides in globals.css, which are untouched. tsc --noEmit clean. Refs: server-26#46, server-26#44, server-26#48 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 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.
|
|
//
|
|
// 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";
|
|
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,
|
|
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>
|
|
)}
|
|
|
|
{/* 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}
|
|
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>
|
|
);
|
|
}
|