"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(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(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 ( {open && (
{text ? (

{text}

) : (

No transcript. Either STT was off when this call landed, or Whisper rejected it as silence or degenerate output.

)} {audioUrl && ( /* eslint-disable-next-line jsx-a11y/media-has-caption */
)}
); } 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([]); const [cursor, setCursor] = useState(null); const [moreAvailable, setMoreAvailable] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [link, setLink] = useState("any"); const [transcript, setTranscript] = useState("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 (
{LINK_FILTERS.map(({ key, label }) => ( ))}
{TRANSCRIPT_FILTERS.map(({ key, label }) => ( ))}
{ e.preventDefault(); setSubmittedQ(q.trim()); }} className="flex items-center gap-2 ml-auto" > 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" />
{calls.length > 0 && (

{calls.length} calls · {orphanCount} orphaned · {noTranscript} without a transcript

)} {/* Gate A / A2 (server-26#46) — every row expands to a transcript. */} {error && } {loading && calls.length === 0 ? (
) : calls.length === 0 && !error ? ( ) : (
{calls.map((call) => ( load(null, false)} /> ))}
)} {moreAvailable && (
)}
); }