Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,6 +24,7 @@ export function App() {
<Route path="/accounts/:id" element={<AccountDetail />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/transactions/:id" element={<TransactionDetail />} />
<Route path="/compliance/cases" element={<Cases />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
Expand Down
8 changes: 8 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]

Expand Down
36 changes: 36 additions & 0 deletions web/src/features/cases/CasesTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<table className="tbl">
<thead>
<tr>
<th>Case ID</th>
<th>Reference</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{cases.map((kase) => (
<tr key={kase.id}>
<td
className="mono"
title={kase.id}
style={{ color: 'var(--text-accent)' }}
>
{kase.id}
</td>
<td>{kase.reference}</td>
<td>
<CaseStatusPill status={kase.status} />
</td>
</tr>
))}
</tbody>
</table>
)
}
20 changes: 20 additions & 0 deletions web/src/features/cases/Pills.tsx
Original file line number Diff line number Diff line change
@@ -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<CaseStatus, string> = {
OPEN: 'pill-teal',
CLAIMED: 'pill-violet',
ESCALATED: 'pill-amber',
RESOLVED: 'pill-grey',
}

export function CaseStatusPill({ status }: { status: CaseStatus }) {
return (
<span className={`pill ${STATUS_CLASS[status] ?? 'pill-grey'}`}>
<span className="dot" />
{status}
</span>
)
}
13 changes: 13 additions & 0 deletions web/src/features/cases/useCases.ts
Original file line number Diff line number Diff line change
@@ -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<CaseResponse[]>(`/v1/compliance/cases?status=${status}`),
})
}
73 changes: 73 additions & 0 deletions web/src/routes/Cases.tsx
Original file line number Diff line number Diff line change
@@ -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<CaseStatus>('OPEN')
const { data, isPending, isError, refetch } = useCases(status)

return (
<Shell activeNav="Compliance">
<div
style={{
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
flex: 1,
minHeight: 0,
}}
>
<div role="tablist" aria-label="Case status" style={{ display: 'flex', gap: 6 }}>
{STATUSES.map((option) => (
<button
key={option}
type="button"
role="tab"
aria-selected={option === status}
className={`btn${option === status ? ' btn-active' : ''}`}
onClick={() => setStatus(option)}
>
{option}
</button>
))}
</div>

<div
className="card"
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ flex: 1, overflow: 'auto' }} className="fc-scroll">
{isPending ? (
<StateMessage icon="activity" text="Loading cases..." />
) : isError ? (
<StateMessage icon="alert" text="Could not load cases.">
<button type="button" className="btn" onClick={() => refetch()}>
Retry
</button>
</StateMessage>
) : data.length === 0 ? (
<StateMessage icon="inbox" text="No cases in this status." />
) : (
<CasesTable cases={data} />
)}
</div>
</div>
</div>
</Shell>
)
}
99 changes: 99 additions & 0 deletions web/src/test/Cases.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/compliance/cases']}>
<ThemeProvider>
<Cases />
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
)
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<Response>(() => {}))
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',
)
})
})
Loading