From 39fd966565aa1e53596997c4f118e54a79880607 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Fri, 19 Jun 2026 11:26:48 -0300 Subject: [PATCH] feat(web): add compliance case board screen on live cases api Add a read-only compliance case board to the sandbox dashboard. It lists cases by status from GET /v1/compliance/cases, with a status filter that defaults to OPEN and refetches on change, and the usual loading, empty and error states with retry. The screen mirrors the accounts and transactions screens: a TanStack Query hook over the shared api client, a typed CaseResponse matching the backend contract, a status pill, and a sidebar entry. All data comes from the API; no fields are invented. Closes #287 --- web/src/App.tsx | 2 + web/src/api/types.ts | 8 +++ web/src/components/Sidebar.tsx | 2 +- web/src/features/cases/CasesTable.tsx | 36 ++++++++++ web/src/features/cases/Pills.tsx | 20 ++++++ web/src/features/cases/useCases.ts | 13 ++++ web/src/routes/Cases.tsx | 73 ++++++++++++++++++++ web/src/test/Cases.test.tsx | 99 +++++++++++++++++++++++++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 web/src/features/cases/CasesTable.tsx create mode 100644 web/src/features/cases/Pills.tsx create mode 100644 web/src/features/cases/useCases.ts create mode 100644 web/src/routes/Cases.tsx create mode 100644 web/src/test/Cases.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 93f5ff8..c2c842b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import { ThemeProvider } from '@/components/ThemeProvider' import { AccountDetail } from '@/routes/AccountDetail' import { Accounts } from '@/routes/Accounts' +import { Cases } from '@/routes/Cases' import { Overview } from '@/routes/Overview' import { TransactionDetail } from '@/routes/TransactionDetail' import { Transactions } from '@/routes/Transactions' @@ -23,6 +24,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 125275f..c9355c1 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -78,3 +78,11 @@ export interface BalanceResponse { amount: string lastPostedAt: string | null } + +export type CaseStatus = 'OPEN' | 'CLAIMED' | 'ESCALATED' | 'RESOLVED' + +export interface CaseResponse { + id: string + reference: string + status: CaseStatus +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 22df2cb..8586825 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -16,7 +16,7 @@ const NAV: NavItem[] = [ { name: 'Transactions', icon: 'arrows', path: '/transactions' }, { name: 'Payments', icon: 'send' }, { name: 'Decisions', icon: 'scale' }, - { name: 'Compliance', icon: 'shield' }, + { name: 'Compliance', icon: 'shield', path: '/compliance/cases' }, { name: 'Audit', icon: 'book' }, ] diff --git a/web/src/features/cases/CasesTable.tsx b/web/src/features/cases/CasesTable.tsx new file mode 100644 index 0000000..5b52541 --- /dev/null +++ b/web/src/features/cases/CasesTable.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import type { CaseResponse } from '@/api/types' +import { CaseStatusPill } from './Pills' + +export function CasesTable({ cases }: { cases: CaseResponse[] }) { + return ( + + + + + + + + + + {cases.map((kase) => ( + + + + + + ))} + +
Case IDReferenceStatus
+ {kase.id} + {kase.reference} + +
+ ) +} diff --git a/web/src/features/cases/Pills.tsx b/web/src/features/cases/Pills.tsx new file mode 100644 index 0000000..baff412 --- /dev/null +++ b/web/src/features/cases/Pills.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import type { CaseStatus } from '@/api/types' + +const STATUS_CLASS: Record = { + OPEN: 'pill-teal', + CLAIMED: 'pill-violet', + ESCALATED: 'pill-amber', + RESOLVED: 'pill-grey', +} + +export function CaseStatusPill({ status }: { status: CaseStatus }) { + return ( + + + {status} + + ) +} diff --git a/web/src/features/cases/useCases.ts b/web/src/features/cases/useCases.ts new file mode 100644 index 0000000..de557c9 --- /dev/null +++ b/web/src/features/cases/useCases.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/api/client' +import type { CaseResponse, CaseStatus } from '@/api/types' + +export function useCases(status: CaseStatus) { + return useQuery({ + queryKey: ['cases', status], + queryFn: () => apiFetch(`/v1/compliance/cases?status=${status}`), + }) +} diff --git a/web/src/routes/Cases.tsx b/web/src/routes/Cases.tsx new file mode 100644 index 0000000..9e1b4ce --- /dev/null +++ b/web/src/routes/Cases.tsx @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useState } from 'react' +import type { CaseStatus } from '@/api/types' +import { Shell } from '@/components/Shell' +import { StateMessage } from '@/components/StateMessage' +import { CasesTable } from '@/features/cases/CasesTable' +import { useCases } from '@/features/cases/useCases' + +const STATUSES: CaseStatus[] = ['OPEN', 'CLAIMED', 'ESCALATED', 'RESOLVED'] + +export function Cases() { + const [status, setStatus] = useState('OPEN') + const { data, isPending, isError, refetch } = useCases(status) + + return ( + +
+
+ {STATUSES.map((option) => ( + + ))} +
+ +
+
+ {isPending ? ( + + ) : isError ? ( + + + + ) : data.length === 0 ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/web/src/test/Cases.test.tsx b/web/src/test/Cases.test.tsx new file mode 100644 index 0000000..7218119 --- /dev/null +++ b/web/src/test/Cases.test.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CaseResponse } from '@/api/types' +import { ThemeProvider } from '@/components/ThemeProvider' +import { Cases } from '@/routes/Cases' + +const SAMPLE: CaseResponse[] = [ + { id: 'case_0001', reference: 'case-ref-1', status: 'OPEN' }, + { id: 'case_0002', reference: 'case-ref-2', status: 'OPEN' }, +] + +function ok(body: unknown) { + return { ok: true, status: 200, json: async () => body } as Response +} + +function renderCases() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const ui: ReactElement = ( + + + + + + + + ) + return render(ui) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('Cases', () => { + it('shows a loading state while the request is in flight', () => { + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise(() => {})) + renderCases() + expect(screen.getByText('Loading cases...')).toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + }) + + it('renders a row per case with only the real contract fields', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(SAMPLE)) + renderCases() + expect(await screen.findByText('case-ref-1')).toBeInTheDocument() + expect(screen.getAllByRole('row')).toHaveLength(SAMPLE.length + 1) + expect(screen.getByText('case_0001')).toBeInTheDocument() + expect(screen.queryByText('Assignee')).not.toBeInTheDocument() + }) + + it('defaults to the OPEN status filter', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(SAMPLE)) + renderCases() + await screen.findByText('case-ref-1') + expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('status=OPEN') + }) + + it('refetches with the selected status when a tab is clicked', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(SAMPLE)) + renderCases() + await screen.findByText('case-ref-1') + + fireEvent.click(screen.getByRole('tab', { name: 'RESOLVED' })) + await waitFor(() => + expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('status=RESOLVED'), + ) + }) + + it('shows an empty state when there are no cases in the status', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok([])) + renderCases() + expect(await screen.findByText('No cases in this status.')).toBeInTheDocument() + }) + + it('shows an error state with a working Retry', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + renderCases() + expect(await screen.findByText('Could not load cases.')).toBeInTheDocument() + const before = fetchMock.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThan(before)) + }) + + it('marks the Compliance sidebar item as the current page', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok(SAMPLE)) + renderCases() + await screen.findByText('case-ref-1') + expect(screen.getByRole('link', { name: 'Compliance' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) +})