& { kind: RoutableEvent["kind"] }): RoutableEvent {
+ return {
+ claimId: null,
+ remittanceId: null,
+ npi: undefined,
+ ...overrides,
+ };
+}
+
+describe("eventKindToUrl", () => {
+ // ----- claim_* → /claims?claim=ID -------------------------------
+ it("routes claim_submitted to /claims?claim=ID", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_submitted", claimId: "CLM-1" })),
+ ).toBe("/claims?claim=CLM-1");
+ });
+
+ it("routes claim_paid to /claims?claim=ID", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM-2" })),
+ ).toBe("/claims?claim=CLM-2");
+ });
+
+ it("routes claim_denied to /claims?claim=ID", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_denied", claimId: "CLM-3" })),
+ ).toBe("/claims?claim=CLM-3");
+ });
+
+ it("routes claim_accepted to /claims?claim=ID", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_accepted", claimId: "CLM-4" })),
+ ).toBe("/claims?claim=CLM-4");
+ });
+
+ it("percent-encodes the claim id when it contains special chars", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM/1+2" })),
+ ).toBe("/claims?claim=CLM%2F1%2B2");
+ });
+
+ it("returns null for claim_* when claimId is missing", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "claim_paid", claimId: null })),
+ ).toBeNull();
+ expect(
+ eventKindToUrl(evt({ kind: "claim_paid", claimId: undefined })),
+ ).toBeNull();
+ });
+
+ // ----- remit_received → null (Phase 4) --------------------------
+ it("returns null for remit_received (Phase 4 drawer not built yet)", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "remit_received", remittanceId: "REM-1" })),
+ ).toBeNull();
+ });
+
+ // ----- provider_added → /providers?provider=NPI -----------------
+ it("routes provider_added to /providers?provider=NPI", () => {
+ expect(
+ eventKindToUrl(evt({ kind: "provider_added", npi: "1730187395" })),
+ ).toBe("/providers?provider=1730187395");
+ });
+
+ it("returns null for provider_added when npi is missing", () => {
+ expect(eventKindToUrl(evt({ kind: "provider_added", npi: undefined }))).toBeNull();
+ });
+
+ // ----- default branch ------------------------------------------
+ it("returns null for unhandled kinds (manual_match)", () => {
+ expect(eventKindToUrl(evt({ kind: "manual_match" }))).toBeNull();
+ });
+
+ it("returns null for unknown kinds (defensive default)", () => {
+ // Cast through unknown so the type-checker doesn't widen the
+ // literal away from the exhaustive union.
+ const unknown = { kind: "future_kind" } as unknown as RoutableEvent;
+ expect(eventKindToUrl(unknown)).toBeNull();
+ });
+});
diff --git a/src/lib/event-routing.ts b/src/lib/event-routing.ts
new file mode 100644
index 0000000..767c99f
--- /dev/null
+++ b/src/lib/event-routing.ts
@@ -0,0 +1,55 @@
+import type { Activity } from "@/types";
+
+/**
+ * The minimum an activity event must carry to be routable. Centralizing
+ * this here means callers can pass an `Activity` (full feed row) or a
+ * narrower shape (e.g. a future "activity event" peek payload) — both
+ * satisfy the kind + entity-id fields the helper inspects.
+ *
+ * - `claimId` / `remittanceId` come from the backend `ActivityEvent`
+ * ORM columns (SP21 Task 2.5). `claimId` is set on `claim_*` events;
+ * `remittanceId` is set on `remit_received`.
+ * - `npi` is the existing `Activity.npi` field. For `provider_added`
+ * events the provider NPI IS the entity id (the URL target).
+ */
+export type RoutableEvent = Pick<
+ Activity,
+ "kind" | "claimId" | "remittanceId" | "npi"
+>;
+
+/**
+ * Maps an activity event to the URL the operator should land on when
+ * clicking the event. Used by:
+ *
+ * - Dashboard "Recent activity" card (this PR, Task 2.5).
+ * - `/activity` log page (Task 2.5 wired here; full integration is
+ * a later task).
+ *
+ * Returns `null` for kinds that don't have a drill target yet, or
+ * when the entity id field is missing on the event. Callers are
+ * expected to surface a "coming soon" toast in that case so the click
+ * still gives feedback.
+ */
+export function eventKindToUrl(event: RoutableEvent): string | null {
+ switch (event.kind) {
+ case "claim_submitted":
+ case "claim_paid":
+ case "claim_denied":
+ case "claim_accepted": {
+ if (!event.claimId) return null;
+ return `/claims?claim=${encodeURIComponent(event.claimId)}`;
+ }
+ case "remit_received":
+ // Phase 4 — the `RemitDrawer` isn't built yet; the helper stays
+ // honest and returns null so the caller's "coming soon" toast
+ // fires. When the drawer lands, the route becomes
+ // `/remittances?remit=${encodeURIComponent(event.remittanceId ?? "")}`.
+ return null;
+ case "provider_added": {
+ if (!event.npi) return null;
+ return `/providers?provider=${encodeURIComponent(event.npi)}`;
+ }
+ default:
+ return null;
+ }
+}
diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx
new file mode 100644
index 0000000..5ecd924
--- /dev/null
+++ b/src/pages/Dashboard.test.tsx
@@ -0,0 +1,151 @@
+// @vitest-environment happy-dom
+// SP21 Task 2.5: Dashboard "Recent activity" card routes clicks by event
+// kind. This test verifies the wiring end-to-end: a `claim_paid` event
+// in the activity slice becomes a clickable row that navigates to
+// `/claims?claim=
`, while a `remit_received` event surfaces a
+// "coming in a later phase" toast (since the RemitDrawer lands in
+// Phase 4).
+//
+// `Dashboard` reads `claims`, `providers`, `activity` from the
+// `useAppStore` slice, so we override the slice directly via
+// `useAppStore.setState` instead of mocking `useActivity` — the
+// sample-data mode is exactly what the dashboard sees when no API is
+// configured, so the test stays faithful to production behavior.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { Dashboard } from "./Dashboard";
+import { useAppStore } from "@/store";
+import type { Activity } from "@/types";
+
+// sonner renders toasts through a portal; jsdom has no layout so the
+// `position` style is a no-op. We just verify that `toast.info` is
+// called with the expected message — sonner itself has its own tests.
+vi.mock("sonner", () => ({
+ toast: {
+ info: vi.fn(),
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Capture navigation side effects so we can assert on the URL the
+// Dashboard would push. We use a `MemoryRouter` (initialEntries=["/"])
+// and observe the rendered route via a tiny listener component that
+// reads the current `` entry — no real `window.history`
+// or `BrowserRouter` required.
+import { useLocation } from "react-router-dom";
+
+function LocationProbe() {
+ const loc = useLocation();
+ return (
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ // Reset the activity slice so tests don't bleed into each other.
+ useAppStore.setState({ activity: [] });
+});
+
+describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
+ it("navigates to /claims?claim=ID when a claim_paid event is clicked", () => {
+ const activity: Activity[] = [
+ {
+ id: "ae-1",
+ kind: "claim_paid",
+ message: "Paid CLM-42 · Jordan Smith",
+ timestamp: "2026-06-20T12:00:00Z",
+ claimId: "CLM-42",
+ remittanceId: null,
+ },
+ ];
+ useAppStore.setState({ activity });
+
+ const { getByTestId, getByRole } = render(
+
+
+
+ ,
+ );
+
+ // The row becomes a button-like with role="button" and a
+ // descriptive aria-label derived from the kind.
+ const row = getByRole("button", { name: /View claim paid/ });
+ fireEvent.click(row);
+
+ const probe = getByTestId("location");
+ expect(probe.getAttribute("data-pathname")).toBe("/claims");
+ expect(probe.getAttribute("data-search")).toBe("?claim=CLM-42");
+ });
+
+ it("navigates to /providers?provider=NPI when a provider_added event is clicked", () => {
+ const activity: Activity[] = [
+ {
+ id: "ae-2",
+ kind: "provider_added",
+ message: "Added Cedar Park Family Medicine",
+ timestamp: "2026-06-20T12:00:00Z",
+ npi: "1730187395",
+ claimId: null,
+ remittanceId: null,
+ },
+ ];
+ useAppStore.setState({ activity });
+
+ const { getByTestId, getByRole } = render(
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByRole("button", { name: /View provider added/ }));
+
+ const probe = getByTestId("location");
+ expect(probe.getAttribute("data-pathname")).toBe("/providers");
+ expect(probe.getAttribute("data-search")).toBe("?provider=1730187395");
+ });
+
+ it("fires the toast (no navigation) for remit_received — Phase 4 drawer", async () => {
+ const { toast } = await import("sonner");
+ const activity: Activity[] = [
+ {
+ id: "ae-3",
+ kind: "remit_received",
+ message: "Remit REM-7 received",
+ timestamp: "2026-06-20T12:00:00Z",
+ claimId: null,
+ remittanceId: "REM-7",
+ },
+ ];
+ useAppStore.setState({ activity });
+
+ const { getByTestId, getByRole } = render(
+
+
+
+ ,
+ );
+
+ fireEvent.click(getByRole("button", { name: /View remit received/ }));
+
+ expect(toast.info).toHaveBeenCalledTimes(1);
+ const msg = (toast.info as ReturnType).mock.calls[0]?.[0] as string;
+ expect(msg).toMatch(/remit received/i);
+ expect(msg).toMatch(/coming in a later phase/i);
+
+ // URL must not have changed — the toast path is non-navigating.
+ const probe = getByTestId("location");
+ expect(probe.getAttribute("data-pathname")).toBe("/");
+ expect(probe.getAttribute("data-search")).toBe("");
+ });
+});
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 2fca5f3..6350565 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -16,7 +16,9 @@ import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
+import { eventKindToUrl } from "@/lib/event-routing";
import { useAppStore } from "@/store";
+import { toast } from "sonner";
const MONTHS_BACK = 6;
@@ -275,7 +277,24 @@ export function Dashboard() {
-
+ {
+ // SP21 Task 2.5: route by event kind. claim_* kinds
+ // navigate to the matching claim drawer (URL-driven,
+ // drawer mounts in Phase 5); provider_added opens the
+ // ProviderDrawer. remit_received and any unmapped
+ // kind surface a "coming soon" toast so the click
+ // still gives feedback until the RemitDrawer lands in
+ // Phase 4.
+ const url = eventKindToUrl(evt);
+ if (url) navigate(url);
+ else
+ toast.info(
+ `Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
+ );
+ }}
+ />
diff --git a/src/store/index.ts b/src/store/index.ts
index 02b7950..497e512 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -49,6 +49,10 @@ export const useAppStore = create((set) => ({
timestamp: claim.submissionDate,
npi: claim.providerNpi,
amount: claim.billedAmount,
+ // SP21 Task 2.5: mirror the backend wire shape so the
+ // Dashboard routing helper finds the claim id.
+ claimId: claim.id,
+ remittanceId: null,
},
...s.activity,
],
diff --git a/src/types/index.ts b/src/types/index.ts
index c48a4d8..498fc6d 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -155,6 +155,23 @@ export interface Activity {
timestamp: string;
npi?: string;
amount?: number;
+ /**
+ * SP21 Task 2.5: read from the backend `ActivityEvent.claim_id` so
+ * the Dashboard "Recent activity" card can route a click on a
+ * `claim_*` event to the matching claim drawer (via
+ * `src/lib/event-routing.ts`). Populated only for events that
+ * correspond to a specific claim; `null` for orphan events such as
+ * `remit_received` or `provider_added`.
+ */
+ claimId?: string | null;
+ /**
+ * SP21 Task 2.5: read from the backend `ActivityEvent.remittance_id`.
+ * Populated only for events that reference a specific remittance
+ * (e.g. `remit_received`); `null` otherwise. The Dashboard toast
+ * covers the Phase 2 case where this routes to a not-yet-built
+ * `RemitDrawer` (Phase 4).
+ */
+ remittanceId?: string | null;
}
// ---------------------------------------------------------------------------