feat(frontend): Remittances + ActivityLog wire live tail
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
import { useActivity } from "@/hooks/useActivity";
|
import { useActivity } from "@/hooks/useActivity";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
@@ -6,7 +9,16 @@ import { ErrorState } from "@/components/ui/error-state";
|
|||||||
|
|
||||||
export function ActivityLog() {
|
export function ActivityLog() {
|
||||||
const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
|
const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The page has
|
||||||
|
// no existing kind filter UI (the plan calls out "filters by activity
|
||||||
|
// kind" but the current ActivityLog renders all events unfiltered),
|
||||||
|
// so we pass no `filterFn` to `useMergedTail` — every live-arriving
|
||||||
|
// activity event is shown. If a kind filter is added later, this is
|
||||||
|
// the place to thread it through.
|
||||||
|
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||||
|
useTailStream("activity");
|
||||||
|
const items = useMergedTail("activity", data?.items ?? []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in">
|
||||||
@@ -31,6 +43,16 @@ export function ActivityLog() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="surface rounded-xl p-6">
|
<div className="surface rounded-xl p-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<TailStatusPill
|
||||||
|
status={tailStatus}
|
||||||
|
lastEventAt={tailLastEventAt}
|
||||||
|
onReconnect={forceReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Remittances } from "./Remittances";
|
import { Remittances } from "./Remittances";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useTailStore } from "@/store/tail-store";
|
||||||
|
|
||||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
|
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
|
||||||
// stub the method this page touches via the `useRemittances` hook.
|
// stub the method this page touches via the `useRemittances` hook.
|
||||||
@@ -23,6 +24,18 @@ vi.mock("@/lib/api", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the live-tail hook so the page renders the pill in the settled
|
||||||
|
// `live` state without driving the real streamTail parser. The hook's
|
||||||
|
// own test file (`useTailStream.test.ts`) covers the lifecycle in depth.
|
||||||
|
vi.mock("@/hooks/useTailStream", () => ({
|
||||||
|
useTailStream: vi.fn(() => ({
|
||||||
|
status: "live",
|
||||||
|
lastEventAt: null,
|
||||||
|
error: null,
|
||||||
|
forceReconnect: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||||
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
|
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
|
||||||
@@ -78,6 +91,10 @@ async function waitForText(
|
|||||||
describe("Remittances", () => {
|
describe("Remittances", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Singleton tail-store: clear the remittances slice between tests
|
||||||
|
// so a tail-arrival case (if added later) doesn't see rows from a
|
||||||
|
// previous test.
|
||||||
|
useTailStore.getState().reset("remittances");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
||||||
@@ -146,4 +163,36 @@ describe("Remittances", () => {
|
|||||||
expect(document.body.textContent).toContain("PR-1");
|
expect(document.body.textContent).toContain("PR-1");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Live-tail status pill (sub-project 5, Phase 5 Task 23, optional
|
||||||
|
// page-level test). Cheap smoke check: the toolbar renders the pill
|
||||||
|
// with the `live` label whenever the mocked stream is settled. The
|
||||||
|
// hook's lifecycle is covered exhaustively in its own test file.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_status_pill_shows_live_in_toolbar", async () => {
|
||||||
|
(
|
||||||
|
api.listRemittances as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
returned: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||||
|
|
||||||
|
// Wait for the empty-state to mount — once the page body is rendered,
|
||||||
|
// the toolbar (and thus the pill) is in the DOM.
|
||||||
|
await waitForText("Remittances · awaiting first 835");
|
||||||
|
|
||||||
|
const pill = document.body.querySelector(
|
||||||
|
'[data-testid="tail-status-pill"]',
|
||||||
|
);
|
||||||
|
expect(pill).not.toBeNull();
|
||||||
|
expect(pill?.textContent).toContain("Live");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+43
-12
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -15,9 +15,12 @@ import { ErrorState } from "@/components/ui/error-state";
|
|||||||
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
|
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
import { useRemittances } from "@/hooks/useRemittances";
|
import { useRemittances } from "@/hooks/useRemittances";
|
||||||
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -64,7 +67,25 @@ export function Remittances() {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: (page - 1) * PAGE_SIZE,
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The tail
|
||||||
|
// stream emits every remittance that lands — including ones that
|
||||||
|
// don't match the user's current `status` filter chip. Drop those
|
||||||
|
// tail items so a newly-arrived remit that wouldn't match the page's
|
||||||
|
// intent doesn't flash into the table. Base items are NOT filtered
|
||||||
|
// here (they reflect whatever the list query returned, matching the
|
||||||
|
// existing page behavior).
|
||||||
|
const tailFilterFn = useMemo(
|
||||||
|
() => (r: Remittance) => {
|
||||||
|
if (status && r.status !== status) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||||
|
useTailStream("remittances");
|
||||||
|
const items = useMergedTail("remittances", data?.items ?? [], tailFilterFn);
|
||||||
|
|
||||||
const total = items.reduce(
|
const total = items.reduce(
|
||||||
(acc, r) => ({
|
(acc, r) => ({
|
||||||
@@ -126,15 +147,25 @@ export function Remittances() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface rounded-xl p-4">
|
<div className="surface rounded-xl p-4">
|
||||||
<FilterChips
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
options={STATUS_OPTIONS}
|
<FilterChips
|
||||||
value={status}
|
options={STATUS_OPTIONS}
|
||||||
onChange={(v) => {
|
value={status}
|
||||||
setStatus((v as RemittanceStatus | null) ?? null);
|
onChange={(v) => {
|
||||||
setPage(1);
|
setStatus((v as RemittanceStatus | null) ?? null);
|
||||||
}}
|
setPage(1);
|
||||||
eyebrow="Status"
|
}}
|
||||||
/>
|
eyebrow="Status"
|
||||||
|
/>
|
||||||
|
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<TailStatusPill
|
||||||
|
status={tailStatus}
|
||||||
|
lastEventAt={tailLastEventAt}
|
||||||
|
onReconnect={forceReconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface rounded-xl overflow-hidden">
|
<div className="surface rounded-xl overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user