frontend: search, filters and load-more on Incidents; open Archive to viewers

Incidents page: text search (title, location, summary, units, vehicles,
tags, location mentions), status and type filters, and a Load more button
that pages the Firestore query 100 at a time. Filtering runs over the
loaded window, and the page says so when older incidents exist.

Archive (/calls): readable by every org member, not just admins.
GET /calls/search now takes any Firebase token scoped to the caller's org
— the Firestore rules already let members read every call in their org,
so this widens nothing. Attach/detach stays admin-only (UI and routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-23 23:53:46 -04:00
co-authored by Claude Opus 5.5
parent 5f85a878fa
commit fa194e0f0a
4 changed files with 108 additions and 23 deletions
+20 -13
View File
@@ -6,8 +6,9 @@
// 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.
// 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";
@@ -68,11 +69,13 @@ function ArchiveRow({
call,
systemName,
incidents,
canEdit,
onChanged,
}: {
call: CallRecord;
systemName?: string;
incidents: IncidentRecord[];
canEdit: boolean;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
@@ -178,18 +181,18 @@ function ArchiveRow({
<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
{canEdit && <button
onClick={() => detach(id)}
disabled={busy}
className="text-sev-major hover:underline disabled:opacity-50 shrink-0"
>
detach
</button>
</button>}
</div>
);
})}
<div className="flex flex-wrap items-center gap-2">
{canEdit && <div className="flex flex-wrap items-center gap-2">
<select
value={attachTo}
onChange={(e) => setAttachTo(e.target.value)}
@@ -208,7 +211,7 @@ function ArchiveRow({
<Button size="sm" variant="secondary" onClick={attach} disabled={!attachTo || busy}>
{busy ? "Saving…" : "Attach"}
</Button>
</div>
</div>}
</div>
{error && <ErrorBanner message={error} />}
@@ -219,8 +222,9 @@ function ArchiveRow({
}
export default function ArchivePage() {
const { isAdmin, loading: authLoading } = useAuth();
const { user, orgId, isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const canView = Boolean(user && (orgId || isAdmin));
const { systems } = useSystems();
const { incidents } = useIncidents(200);
@@ -237,8 +241,8 @@ export default function ArchivePage() {
const [submittedQ, setSubmittedQ] = useState("");
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/");
}, [authLoading, isAdmin, router]);
if (!authLoading && !canView) router.replace("/");
}, [authLoading, canView, router]);
const load = useCallback(
async (nextCursor: string | null, append: boolean) => {
@@ -267,9 +271,9 @@ export default function ArchivePage() {
// Reload from the top whenever a filter changes.
useEffect(() => {
if (authLoading || !isAdmin) return;
if (authLoading || !canView) return;
load(null, false);
}, [authLoading, isAdmin, load]);
}, [authLoading, canView, load]);
const systemName = useMemo(() => {
const m = new Map(systems.map((s) => [s.system_id, s.name]));
@@ -277,7 +281,7 @@ export default function ArchivePage() {
}, [systems]);
// Every hook runs before this guard — see the note in app/nodes/page.tsx.
if (authLoading || !isAdmin) return null;
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;
@@ -286,7 +290,9 @@ export default function ArchivePage() {
<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."
description={isAdmin
? "Every call on the account, correlated or not. Attach an orphan to the incident it belongs to, or detach one the correlator got wrong."
: "Every call on the account, correlated or not."}
/>
<div className="flex flex-wrap items-center gap-3">
@@ -373,6 +379,7 @@ export default function ArchivePage() {
call={call}
systemName={systemName(call.system_id)}
incidents={incidents}
canEdit={isAdmin}
onChanged={() => load(null, false)}
/>
))}