import { useCallback, useMemo } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { useActivity } from "@/hooks/useActivity"; import { useTailStream } from "@/hooks/useTailStream"; import { useMergedTail } from "@/hooks/useMergedTail"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { eventKindToUrl } from "@/lib/event-routing"; import { TailStatusPill } from "@/components/TailStatusPill"; import { PageHeader } from "@/components/PageHeader"; import { ActivityFeed } from "@/components/ActivityFeed"; import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters"; import { RemitDrawer } from "@/components/RemitDrawer"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; import type { ActivityKind } from "@/types"; const VALID_KINDS: readonly ActivityKind[] = [ "claim_submitted", "claim_paid", "claim_denied", "claim_accepted", "remit_received", "provider_added", ]; const VALID_SINCE: readonly SinceValue[] = ["1h", "24h", "7d", "all"]; function useSinceIso(since: SinceValue): string | undefined { return useMemo(() => { const now = Date.now(); if (since === "1h") return new Date(now - 60 * 60 * 1000).toISOString(); if (since === "24h") return new Date(now - 24 * 60 * 60 * 1000).toISOString(); if (since === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(); return undefined; }, [since]); } export function ActivityLog() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); // SP21 Phase 4 Task 4.7: `remit_received` events with a // remittanceId drill into the RemitDrawer. Calling `open(id)` // pushes `?remit=ID` onto the current URL (no navigation away // from `/activity`), so the activity feed stays visible behind // the drawer and the drawer portals in over it. const { remitId, open, close } = useRemitDrawerUrlState(); const selectedKinds = useMemo( () => searchParams .getAll("kind") .filter((k): k is ActivityKind => (VALID_KINDS as readonly string[]).includes(k), ), [searchParams], ); const sinceRaw = searchParams.get("since") ?? "all"; const since: SinceValue = (VALID_SINCE as readonly string[]).includes(sinceRaw) ? (sinceRaw as SinceValue) : "all"; const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined; const sinceIso = useSinceIso(since); const { data, isLoading, isError, error, refetch } = useActivity({ kind: apiKind, since: sinceIso, // 500 keeps the wire size manageable (and the operator can rely // on the live tail to surface new events as they land). The // activity list endpoint doesn't expose a true total — `data.total` // reflects events matching the current kind/since filter, capped // at the request limit — so the eyebrow below uses "Showing N // most recent" instead of "X of Y" to avoid a misleading ratio. limit: 500, }); const allItems = data?.items ?? []; const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } = useTailStream("activity"); const merged = useMergedTail("activity", allItems); const items = useMemo(() => { if (selectedKinds.length <= 1) return merged; return merged.filter((a) => selectedKinds.includes(a.kind)); }, [merged, selectedKinds]); const writeParams = useCallback( (mutate: (next: URLSearchParams) => void) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); mutate(next); return next; }, { replace: true }, ); }, [setSearchParams], ); const setSelectedKinds = useCallback( (kinds: ActivityKind[]) => { writeParams((next) => { next.delete("kind"); for (const k of kinds) next.append("kind", k); }); }, [writeParams], ); const setSince = useCallback( (value: SinceValue) => { writeParams((next) => { if (value === "all") next.delete("since"); else next.set("since", value); }); }, [writeParams], ); const clearFilters = useCallback(() => { writeParams((next) => { next.delete("kind"); next.delete("since"); }); }, [writeParams]); const hasFilters = selectedKinds.length > 0 || since !== "all"; return (
0 ? `Activity · showing ${allItems.length.toLocaleString()} most recent` : "Activity" } title={<>Activity log} subtitle="Every claim submission, denial, payment, and provider event, in reverse chronological order. Auto-refreshes every 30s." actions={ } />
{isError ? ( refetch()} /> ) : null}
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? ( ) : ( { // SP21 Phase 4 Task 4.7: drill into the right surface // based on event kind. `claim_*` and `provider_added` // navigate away via `eventKindToUrl` (the Dashboard uses // the same helper). `remit_received` events stay on // `/activity` and open the RemitDrawer via `open(id)` // — `eventKindToUrl` still returns `null` for that kind // because cross-page navigation isn't the right UX here // (we want to keep the activity feed as context behind // the drawer). Anything else falls back to the // "coming soon" toast so the click still gives feedback. const url = eventKindToUrl(evt); if (url) navigate(url); else if (evt.kind === "remit_received" && evt.remittanceId) { open(evt.remittanceId); } else { toast.info( `Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`, ); } }} /> )}
{/* SP21 Phase 4 Task 4.7: RemitDrawer mount. The activity feed's `remit_received` rows drill into the drawer via `open()`. `remits` is empty (the activity feed doesn't keep a flat list of remits around), so j/k is a no-op here — closing reverts the URL via `close()`. */} { // ActivityLog has no cheatsheet; `?` is a no-op here. }} />
); }