Backend serializes SQLite-read (naive) UTC datetimes without a Z, so the browser was parsing them as local time -> the UI effectively showed UTC values. - New src/utils/datetime.ts: treats a tz-less timestamp as UTC, then formats in the browser's local timezone with hour12:false (no AM/PM). - Applied to Energy tabs (contracts/prices/costs/DSMR), per-device readings & trends, and the meter readings table on the Records page. - CostView Today/This month now use local day boundaries (queries stay UTC ISO). - Untouched: location/poo (raw Zulu strings), the map, and query-window params. - Tests are timezone-independent (verified under TZ=America/Los_Angeles).
511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
/**
|
|
* RecordsPage — paginated lists + edit/delete for poo and location records (M2-T10).
|
|
* Meter readings tab (read-only) per Modbus device dynamically appended (M5).
|
|
*
|
|
* - 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).
|
|
* - Meter tabs: one read-only tab per Modbus device; readings from /readings (last 24h, limit 1000).
|
|
*/
|
|
|
|
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'
|
|
import { useDevices, useMetrics, useReadings } from '../energy/hooks'
|
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
|
import { formatMetricValue } from '../energy/format'
|
|
import { formatLocalDateTime } from '../utils/datetime'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MeterReadingsTable — read-only table for a single Modbus device's readings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** How many ms of history to show per meter tab (last 24 h). */
|
|
const METER_SPAN_MS = 24 * 60 * 60 * 1000
|
|
/** Upper bound on readings fetched; never a full-table pull. */
|
|
const METER_READINGS_LIMIT = 1000
|
|
|
|
interface MeterReadingsTableProps {
|
|
device: ModbusDevice
|
|
}
|
|
|
|
/** Format an ISO timestamp for display (local timezone, 24-hour). */
|
|
function formatRecordedAt(iso: string): string {
|
|
return formatLocalDateTime(iso)
|
|
}
|
|
|
|
function MeterReadingsTable({ device }: MeterReadingsTableProps) {
|
|
const metricsQuery = useMetrics(device.uuid)
|
|
const readingsQuery = useReadings(device.uuid, {
|
|
spanMs: METER_SPAN_MS,
|
|
limit: METER_READINGS_LIMIT,
|
|
})
|
|
|
|
const isLoading = metricsQuery.isLoading || readingsQuery.isLoading
|
|
const isError = metricsQuery.isError || readingsQuery.isError
|
|
|
|
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
|
|
const readings = readingsQuery.data?.items ?? []
|
|
const isTruncated = !isLoading && !isError && readings.length >= METER_READINGS_LIMIT
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center pt="xl" data-testid={`meter-loading-${device.uuid}`}>
|
|
<Loader />
|
|
</Center>
|
|
)
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<Alert color="red" data-testid={`meter-error-${device.uuid}`}>
|
|
Failed to load meter readings. Please refresh.
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
const colSpan = 1 + metrics.length // time column + one per metric
|
|
|
|
return (
|
|
<Stack gap="sm">
|
|
{isTruncated && (
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
ta="right"
|
|
data-testid={`meter-truncated-${device.uuid}`}
|
|
>
|
|
Showing the most recent {METER_READINGS_LIMIT} readings (within the last 24h)
|
|
</Text>
|
|
)}
|
|
<ScrollArea>
|
|
<Table striped highlightOnHover withTableBorder data-testid={`meter-table-${device.uuid}`}>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th style={{ whiteSpace: 'nowrap' }}>Recorded At</Table.Th>
|
|
{metrics.map((m) => (
|
|
<Table.Th key={m.key} style={{ whiteSpace: 'nowrap' }}>
|
|
{m.label}
|
|
{m.unit ? (
|
|
<Text component="span" size="xs" c="dimmed" ml={4}>
|
|
({m.unit})
|
|
</Text>
|
|
) : null}
|
|
</Table.Th>
|
|
))}
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{readings.length === 0 ? (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={colSpan}>
|
|
<Text c="dimmed" ta="center" size="sm" data-testid={`meter-empty-${device.uuid}`}>
|
|
No readings in the last 24 hours.
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
) : (
|
|
readings.map((row) => {
|
|
const payload = row.payload as Record<string, unknown>
|
|
return (
|
|
<Table.Tr
|
|
key={row.recorded_at}
|
|
data-testid={`meter-row-${device.uuid}`}
|
|
>
|
|
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
|
{formatRecordedAt(row.recorded_at)}
|
|
</Table.Td>
|
|
{metrics.map((m) => (
|
|
<Table.Td key={m.key} data-testid={`meter-cell-${device.uuid}-${m.key}`}>
|
|
{formatMetricValue(payload[m.key], m)}
|
|
</Table.Td>
|
|
))}
|
|
</Table.Tr>
|
|
)
|
|
})
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea>
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RecordsPage — top-level
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function RecordsPage() {
|
|
const devicesQuery = useDevices()
|
|
const devices: ModbusDevice[] = devicesQuery.data?.items ?? []
|
|
|
|
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>
|
|
{devices.map((d) => (
|
|
<Tabs.Tab key={d.uuid} value={d.uuid} data-testid={`tab-meter-${d.uuid}`}>
|
|
{d.friendly_name}
|
|
</Tabs.Tab>
|
|
))}
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="poo">
|
|
<PooList />
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="locations">
|
|
<LocationList />
|
|
</Tabs.Panel>
|
|
|
|
{devices.map((d) => (
|
|
<Tabs.Panel key={d.uuid} value={d.uuid}>
|
|
<MeterReadingsTable device={d} />
|
|
</Tabs.Panel>
|
|
))}
|
|
</Tabs>
|
|
</Container>
|
|
)
|
|
}
|