M2-T10: build records management UI (paginated lists + single-record CRUD)
- reusable src/records/ module: useUpdate/useDelete Poo+Location hooks (encodeURIComponent PK, prefix-based query invalidation), EditPooModal, EditLocationModal, ConfirmDeleteModal — exported for the map (T09) to reuse - RecordsPage (/records): paginated poo + location tables (page size 100), edit + delete-with-confirm, refresh on success - query keys ['poo']/['locations'] so map and list invalidations cross-cut - typed client only; vitest tests
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Tests for RecordsPage (M2-T10).
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Poo list renders from mocked apiClient GET /api/poo.
|
||||
* 2. Poo pagination: page 2 requests offset=100.
|
||||
* 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded)
|
||||
* PK in the path params (openapi-fetch encodes once; we must not double-encode).
|
||||
* 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes.
|
||||
* 5. Location list renders from mocked apiClient GET /api/locations.
|
||||
* 6. Location pagination: page 2 requests offset=100.
|
||||
* 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params.
|
||||
* 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE.
|
||||
* 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding
|
||||
* (%3A present, %253A absent) for PKs containing colons.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { RecordsPage } from './RecordsPage'
|
||||
import type { LocationRecord } from '../records'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POO_RECORD = {
|
||||
timestamp: '2026-06-12T10:00:00Z',
|
||||
status: 'done',
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
}
|
||||
|
||||
const POO_RECORD_2 = {
|
||||
timestamp: '2026-06-12T11:00:00Z',
|
||||
status: 'pending',
|
||||
latitude: 51.6,
|
||||
longitude: -0.2,
|
||||
}
|
||||
|
||||
const LOCATION_RECORD: LocationRecord = {
|
||||
person: 'alice',
|
||||
datetime: '2026-06-12T09:00:00Z',
|
||||
latitude: 52.0,
|
||||
longitude: 1.0,
|
||||
altitude: 10,
|
||||
}
|
||||
|
||||
// Build a page of 100 items (all identical except for timestamp offset).
|
||||
function makePooPage(offset: number) {
|
||||
return Array.from({ length: 100 }, (_, i) => ({
|
||||
timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
|
||||
status: 'done',
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
}))
|
||||
}
|
||||
|
||||
function makeLocationPage(offset: number): LocationRecord[] {
|
||||
return Array.from({ length: 100 }, (_, i) => ({
|
||||
person: 'alice',
|
||||
datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
|
||||
latitude: 52.0,
|
||||
longitude: 1.0,
|
||||
altitude: null,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPatch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: (...args: unknown[]) => mockGet(...args),
|
||||
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||
DELETE: (...args: unknown[]) => mockDelete(...args),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderRecords() {
|
||||
return renderWithProviders(<RecordsPage />, { initialPath: '/records' })
|
||||
}
|
||||
|
||||
/** Make GET mock respond based on path. */
|
||||
function setupGetMock({
|
||||
pooItems = [POO_RECORD],
|
||||
locationItems = [LOCATION_RECORD],
|
||||
pooOffset = 0,
|
||||
locationOffset = 0,
|
||||
}: {
|
||||
pooItems?: typeof POO_RECORD[]
|
||||
locationItems?: typeof LOCATION_RECORD[]
|
||||
pooOffset?: number
|
||||
locationOffset?: number
|
||||
} = {}) {
|
||||
mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => {
|
||||
const offset = opts?.params?.query?.offset ?? 0
|
||||
if (path === '/api/poo') {
|
||||
return Promise.resolve({
|
||||
data: { items: pooItems, limit: 100, offset: pooOffset || offset },
|
||||
response: { status: 200, ok: true },
|
||||
})
|
||||
}
|
||||
if (path === '/api/locations') {
|
||||
return Promise.resolve({
|
||||
data: { items: locationItems, limit: 100, offset: locationOffset || offset },
|
||||
response: { status: 200, ok: true },
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecordsPage — Poo tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupGetMock()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Poo list renders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders poo records from GET /api/poo', async () => {
|
||||
renderRecords()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
|
||||
expect(screen.getByText('done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Poo pagination: page 2 sends offset=100
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('requests offset=100 when page 2 is selected', async () => {
|
||||
// Return full page to trigger pagination display.
|
||||
const page1 = makePooPage(0)
|
||||
setupGetMock({ pooItems: page1 })
|
||||
|
||||
renderRecords()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('poo-pagination')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click page 2.
|
||||
const page2Button = screen.getByRole('button', { name: '2' })
|
||||
fireEvent.click(page2Button)
|
||||
|
||||
await waitFor(() => {
|
||||
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo')
|
||||
const page2Call = allCalls.find(
|
||||
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
|
||||
)
|
||||
expect(page2Call).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Edit poo: opens modal, submit calls PATCH with encoded PK
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => {
|
||||
mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } })
|
||||
renderRecords()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`))
|
||||
|
||||
// Modal appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Change status
|
||||
const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement
|
||||
fireEvent.change(statusInput, { target: { value: 'reviewed' } })
|
||||
|
||||
// Submit
|
||||
fireEvent.submit(screen.getByTestId('edit-poo-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const patchCall = mockPatch.mock.calls[0]
|
||||
expect(patchCall[0]).toBe('/api/poo/{timestamp}')
|
||||
// PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode.
|
||||
expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
|
||||
// Body must include only non-PK fields
|
||||
expect(patchCall[1].body).toHaveProperty('status')
|
||||
expect(patchCall[1].body).not.toHaveProperty('timestamp')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Delete poo: confirmation then DELETE called; list refreshes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => {
|
||||
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
|
||||
renderRecords()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click Delete — confirmation modal appears
|
||||
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The modal should show a helpful message
|
||||
expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument()
|
||||
|
||||
// Cancel first — modal should close
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Reopen and confirm
|
||||
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const deleteCall = mockDelete.mock.calls[0]
|
||||
expect(deleteCall[0]).toBe('/api/poo/{timestamp}')
|
||||
// PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once.
|
||||
expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecordsPage — Locations tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupGetMock()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Location list renders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders location records after switching to Locations tab', async () => {
|
||||
renderRecords()
|
||||
|
||||
// Switch to Locations tab
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-locations'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Location pagination: page 2 sends offset=100
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('requests offset=100 for locations when page 2 is selected', async () => {
|
||||
const page1 = makeLocationPage(0)
|
||||
setupGetMock({ locationItems: page1 })
|
||||
|
||||
renderRecords()
|
||||
|
||||
// Switch to Locations tab
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('tab-locations'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('location-pagination')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const page2Button = screen.getByRole('button', { name: '2' })
|
||||
fireEvent.click(page2Button)
|
||||
|
||||
await waitFor(() => {
|
||||
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations')
|
||||
const page2Call = allCalls.find(
|
||||
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
|
||||
)
|
||||
expect(page2Call).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Edit location: opens modal, submit calls PATCH with encoded PK
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => {
|
||||
mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } })
|
||||
|
||||
renderRecords()
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-locations'))
|
||||
|
||||
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// PK shown read-only in the modal (may appear more than once in the page: table + modal)
|
||||
const modalEl = screen.getByTestId('edit-location-modal')
|
||||
expect(modalEl).toBeInTheDocument()
|
||||
// 'alice' and datetime appear in modal read-only text
|
||||
expect(modalEl.textContent).toContain('alice')
|
||||
expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z')
|
||||
|
||||
// Submit
|
||||
fireEvent.submit(screen.getByTestId('edit-location-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const patchCall = mockPatch.mock.calls[0]
|
||||
expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}')
|
||||
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
|
||||
expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person)
|
||||
expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
|
||||
// Body must NOT contain PK fields
|
||||
expect(patchCall[1].body).not.toHaveProperty('person')
|
||||
expect(patchCall[1].body).not.toHaveProperty('datetime')
|
||||
expect(patchCall[1].body).toHaveProperty('latitude')
|
||||
expect(patchCall[1].body).toHaveProperty('longitude')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Delete location: confirmation then DELETE called
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => {
|
||||
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
|
||||
|
||||
renderRecords()
|
||||
fireEvent.click(screen.getByTestId('tab-locations'))
|
||||
|
||||
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const deleteCall = mockDelete.mock.calls[0]
|
||||
expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}')
|
||||
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
|
||||
expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person)
|
||||
expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: multiple poo records with correct timestamps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('RecordsPage — multiple poo rows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] })
|
||||
})
|
||||
|
||||
it('renders both rows', async () => {
|
||||
renderRecords()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
||||
*
|
||||
* - Poo list: GET /api/poo, query key ['poo', {limit, offset}], page size 100.
|
||||
* - Location list: GET /api/locations, query key ['locations', {limit, offset}], page size 100.
|
||||
* - Edit and delete use reusable components from src/records/.
|
||||
* - Delete has a二次确认 modal before calling DELETE.
|
||||
* - Pagination with Mantine Pagination; next/prev fetches per-page (no full-table pull).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Table,
|
||||
Pagination,
|
||||
Button,
|
||||
Group,
|
||||
Tabs,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Stack,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
} from '@mantine/core'
|
||||
import apiClient from '../api/client'
|
||||
import { EditPooModal, EditLocationModal, ConfirmDeleteModal } from '../records'
|
||||
import { useDeletePoo, useDeleteLocation } from '../records'
|
||||
import type { PooRecord, LocationRecord } from '../records'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Poo list section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PooList() {
|
||||
const [page, setPage] = useState(1)
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['poo', { limit: PAGE_SIZE, offset }],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/poo', {
|
||||
params: { query: { limit: PAGE_SIZE, offset } },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const [editRecord, setEditRecord] = useState<PooRecord | null>(null)
|
||||
const [deleteRecord, setDeleteRecord] = useState<PooRecord | null>(null)
|
||||
|
||||
const deleteMutation = useDeletePoo()
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteRecord) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteRecord.timestamp)
|
||||
setDeleteRecord(null)
|
||||
} catch {
|
||||
// Leave the modal open so the user can retry; error display is in the modal loading state.
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center pt="xl" data-testid="poo-loading">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert color="red" data-testid="poo-load-error">
|
||||
Failed to load poo records. Please refresh.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed" data-testid="poo-count">
|
||||
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
|
||||
</Text>
|
||||
<Badge variant="outline" color="orange">
|
||||
offset {offset}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ScrollArea>
|
||||
<Table striped highlightOnHover withTableBorder data-testid="poo-table">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Timestamp</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Latitude</Table.Th>
|
||||
<Table.Th>Longitude</Table.Th>
|
||||
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
No records.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<Table.Tr key={row.timestamp} data-testid={`poo-row-${row.timestamp}`}>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.timestamp}</Table.Td>
|
||||
<Table.Td>{row.status}</Table.Td>
|
||||
<Table.Td>{row.latitude}</Table.Td>
|
||||
<Table.Td>{row.longitude}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => setEditRecord(row)}
|
||||
data-testid={`poo-edit-${row.timestamp}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteRecord(row)}
|
||||
data-testid={`poo-delete-${row.timestamp}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={totalPages}
|
||||
data-testid="poo-pagination"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editRecord && (
|
||||
<EditPooModal
|
||||
record={editRecord}
|
||||
onClose={() => setEditRecord(null)}
|
||||
onSaved={() => setEditRecord(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteRecord && (
|
||||
<ConfirmDeleteModal
|
||||
message={`Delete poo record at ${deleteRecord.timestamp}?`}
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteRecord(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Location list section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LocationList() {
|
||||
const [page, setPage] = useState(1)
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['locations', { limit: PAGE_SIZE, offset }],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/locations', {
|
||||
params: { query: { limit: PAGE_SIZE, offset } },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const [editRecord, setEditRecord] = useState<LocationRecord | null>(null)
|
||||
const [deleteRecord, setDeleteRecord] = useState<LocationRecord | null>(null)
|
||||
|
||||
const deleteMutation = useDeleteLocation()
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteRecord) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync({
|
||||
person: deleteRecord.person,
|
||||
datetime: deleteRecord.datetime,
|
||||
})
|
||||
setDeleteRecord(null)
|
||||
} catch {
|
||||
// Leave modal open.
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center pt="xl" data-testid="location-loading">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert color="red" data-testid="location-load-error">
|
||||
Failed to load location records. Please refresh.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const totalPages = data.items.length === PAGE_SIZE ? page + 1 : page
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed" data-testid="location-count">
|
||||
Page {page} · {data.items.length} record{data.items.length !== 1 ? 's' : ''} shown
|
||||
</Text>
|
||||
<Badge variant="outline" color="blue">
|
||||
offset {offset}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ScrollArea>
|
||||
<Table striped highlightOnHover withTableBorder data-testid="location-table">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Person</Table.Th>
|
||||
<Table.Th>Datetime</Table.Th>
|
||||
<Table.Th>Latitude</Table.Th>
|
||||
<Table.Th>Longitude</Table.Th>
|
||||
<Table.Th>Altitude</Table.Th>
|
||||
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" size="sm">
|
||||
No records.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.items.map((row) => {
|
||||
const rowKey = `${row.person}__${row.datetime}`
|
||||
return (
|
||||
<Table.Tr key={rowKey} data-testid={`location-row-${rowKey}`}>
|
||||
<Table.Td>{row.person}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>{row.datetime}</Table.Td>
|
||||
<Table.Td>{row.latitude}</Table.Td>
|
||||
<Table.Td>{row.longitude}</Table.Td>
|
||||
<Table.Td>{row.altitude ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => setEditRecord(row)}
|
||||
data-testid={`location-edit-${rowKey}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteRecord(row)}
|
||||
data-testid={`location-delete-${rowKey}`}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={totalPages}
|
||||
data-testid="location-pagination"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editRecord && (
|
||||
<EditLocationModal
|
||||
record={editRecord}
|
||||
onClose={() => setEditRecord(null)}
|
||||
onSaved={() => setEditRecord(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteRecord && (
|
||||
<ConfirmDeleteModal
|
||||
message={`Delete location record for ${deleteRecord.person} at ${deleteRecord.datetime}?`}
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteRecord(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecordsPage — top-level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecordsPage() {
|
||||
return (
|
||||
<Container size="xl" pt="xl" pb="xl" data-testid="records-page">
|
||||
<Title order={2} mb="lg">
|
||||
Records
|
||||
</Title>
|
||||
|
||||
<Tabs defaultValue="poo">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="poo" data-testid="tab-poo">
|
||||
Poo
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="locations" data-testid="tab-locations">
|
||||
Locations
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="poo">
|
||||
<PooList />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="locations">
|
||||
<LocationList />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user