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:
co-authored by
Claude Opus 5.5
parent
5f85a878fa
commit
fa194e0f0a
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user