222 lines
7.4 KiB
TypeScript
222 lines
7.4 KiB
TypeScript
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<ActivityKind[]>(
|
|
() =>
|
|
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,
|
|
limit: 200,
|
|
});
|
|
|
|
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 (
|
|
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
|
<PageHeader
|
|
eyebrow="Activity"
|
|
title={<>Activity <span className="display italic text-muted-foreground">log</span></>}
|
|
subtitle="Every claim submission, denial, payment, and provider event, in reverse chronological order. Auto-refreshes every 30s."
|
|
actions={
|
|
<TailStatusPill
|
|
status={tailStatus}
|
|
lastEventAt={tailLastEventAt}
|
|
onReconnect={forceReconnect}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<div className="surface rounded-xl p-4">
|
|
<ActivityFilters
|
|
selectedKinds={selectedKinds}
|
|
onKindsChange={setSelectedKinds}
|
|
since={since}
|
|
onSinceChange={setSince}
|
|
onClear={clearFilters}
|
|
/>
|
|
</div>
|
|
|
|
{isError ? (
|
|
<ErrorState
|
|
message="Couldn't load activity from the backend."
|
|
detail={error instanceof Error ? error.message : String(error)}
|
|
onRetry={() => refetch()}
|
|
/>
|
|
) : null}
|
|
|
|
<div className="surface rounded-xl p-6">
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} variant="row" />
|
|
))}
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<EmptyState
|
|
eyebrow={hasFilters ? "Activity · no matches" : "Activity · log idle"}
|
|
message={
|
|
hasFilters
|
|
? "No activity matches the current filters."
|
|
: "Activity will appear here after the first parse."
|
|
}
|
|
/>
|
|
) : (
|
|
<ActivityFeed
|
|
items={items}
|
|
emptyMessage="No activity recorded yet."
|
|
onItemClick={(evt) => {
|
|
// 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.`,
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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()`. */}
|
|
<RemitDrawer
|
|
remitId={remitId}
|
|
remits={[]}
|
|
onClose={close}
|
|
onNavigate={open}
|
|
onToggleHelp={() => {
|
|
// ActivityLog has no cheatsheet; `?` is a no-op here.
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|