M5: add read-only per-meter readings tabs to Records page; English-only UI strings
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 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'
|
||||
@@ -30,6 +32,9 @@ 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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -341,11 +346,133 @@ function LocationList() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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. */
|
||||
function formatRecordedAt(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return 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">
|
||||
@@ -360,6 +487,11 @@ export function RecordsPage() {
|
||||
<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">
|
||||
@@ -369,6 +501,12 @@ export function RecordsPage() {
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user