M8-R13: simplify prices and meter controls
frontend / frontend (push) Successful in 53s
pytest / test (push) Successful in 4m34s
docker-image / build-and-push (push) Successful in 1m49s

This commit is contained in:
2026-08-27 20:01:41 +02:00
parent c9ce05d95a
commit b472f91f19
4 changed files with 145 additions and 34 deletions
+42 -18
View File
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
return target
}
function BindingTimeline({ binding, sources }: {
binding: NonNullable<MeterResponse['bindings']>[number]
sources: ReturnType<typeof useSources>
}) {
const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid)
const channels = useSourceChannels(source?.uuid ?? null)
const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid)
let endpoint = 'Source details unavailable'
if (sources.isLoading) endpoint = 'Loading source details…'
else if (!sources.isError && source) endpoint = source.name
else if (!sources.isError) endpoint = 'Source unavailable'
let channelName = 'Channel details unavailable'
if (source && channels.isLoading) channelName = 'Loading channel details…'
else if (source && !channels.isError && channel) channelName = channel.label
else if (source && !channels.isError) channelName = 'Channel unavailable'
return (
<Stack gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{endpoint} {channelName}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps {
meters: MeterResponse[]
sources: ReturnType<typeof useSources>
onEdit: (meter: MeterResponse) => void
onClose: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function MeterTable({ meters, sources, onEdit, onClose }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
@@ -585,30 +614,24 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
</Table.Td>
<Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{binding.source_uuid} {binding.source_channel_uuid}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
<BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
)) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
<Button
size="xs"
variant="outline"
variant="light"
onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`}
>
Edit
</Button>
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
</Group>
</Table.Td>
</Table.Tr>
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false)
return <>{<Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
return <>{<Button size="xs" variant="light" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
}
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
@@ -676,8 +699,8 @@ function BindingActions({ meter, binding, meters }: { meter: MeterResponse; bind
const recoverySourceIsClosable = meter.ended_at === null ||
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
return <>
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</Button>
{meter.ended_at === null ? <Button size="xs" variant="subtle" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
{meter.ended_at === null ? <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
<Button size="xs" variant="light" onClick={() => setUnbindOpened(true)}>Unbind</Button>
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
@@ -793,6 +816,7 @@ function CloseMeterModal({ meter, onClose }: { meter: MeterResponse; onClose: ()
export function MeterManager() {
const metersQuery = useMeters()
const sources = useSources()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
@@ -845,7 +869,7 @@ export function MeterManager() {
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
<MeterTable meters={meters} sources={sources} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (