M8-R03: hand off channel binding during meter swap
This commit is contained in:
@@ -83,11 +83,12 @@ function toLocalDateInputString(d: Date): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeclareMeterFormProps {
|
||||
meters: MeterResponse[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) {
|
||||
const [label, setLabel] = useState('')
|
||||
const [dateStr, setDateStr] = useState('')
|
||||
const [reason, setReason] = useState<string | null>(null)
|
||||
@@ -100,8 +101,43 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
const channels = useSourceChannels(sourceUuid)
|
||||
|
||||
const declareMutation = useDeclareMeter()
|
||||
const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) =>
|
||||
({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
||||
const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record<string, string>)[commodity ?? 'electricity']
|
||||
const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null)
|
||||
const isChannelEligible = (uuid: string, startedAt: string) => {
|
||||
const channel = channels.data?.items.find((item) => item.uuid === uuid)
|
||||
if (!channel || channel.unit !== expectedUnit) return false
|
||||
|
||||
// Channel aggregates include closed binding history. For a meter swap, only
|
||||
// currently open bindings determine whether this channel can be handed off.
|
||||
const openBindings = meters.flatMap((meter) =>
|
||||
(meter.bindings ?? [])
|
||||
.filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null)
|
||||
.map((binding) => ({ meter, binding })),
|
||||
)
|
||||
const isUnbound = channel.binding_count === 0 &&
|
||||
channel.bound_meter_ids.length === 0 && openBindings.length === 0
|
||||
const oldBinding = openBindings[0]
|
||||
const canHandoff = reason === 'meter_swap' && oldMeter !== undefined &&
|
||||
openBindings.length === 1 && oldBinding !== undefined &&
|
||||
oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity &&
|
||||
startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at))
|
||||
return isUnbound || canHandoff
|
||||
}
|
||||
const channelOptions = channels.data?.items
|
||||
.filter((channel) => channel.unit === expectedUnit)
|
||||
.map((channel) => {
|
||||
const canHandoff = !(
|
||||
channel.binding_count === 0 && channel.bound_meter_ids.length === 0
|
||||
) && isChannelEligible(channel.uuid, dateStr)
|
||||
return {
|
||||
value: channel.uuid,
|
||||
label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`,
|
||||
disabled: !isChannelEligible(channel.uuid, dateStr),
|
||||
}
|
||||
}) ?? []
|
||||
const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr)
|
||||
? channelUuid
|
||||
: null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -127,7 +163,7 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
reason: reason as MeterReason,
|
||||
note: note.trim() || undefined,
|
||||
commodity: commodity ?? 'electricity',
|
||||
...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
|
||||
...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}),
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
@@ -166,7 +202,11 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
type="date"
|
||||
required
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.currentTarget.value)}
|
||||
onChange={(e) => {
|
||||
const nextDateStr = e.currentTarget.value
|
||||
setDateStr(nextDateStr)
|
||||
setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid)
|
||||
}}
|
||||
data-testid="meter-started-at"
|
||||
/>
|
||||
|
||||
@@ -175,17 +215,17 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
required
|
||||
data={REASON_OPTIONS}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
onChange={(value) => { setReason(value); setChannelUuid(null) }}
|
||||
data-testid="meter-reason"
|
||||
/>
|
||||
|
||||
<Select label="Commodity" value={commodity} onChange={setCommodity} data={[
|
||||
<Select label="Commodity" value={commodity} onChange={(value) => { setCommodity(value); setChannelUuid(null) }} data={[
|
||||
{ value: 'electricity', label: 'Electricity' },
|
||||
{ value: 'heating', label: 'Heating' },
|
||||
{ value: 'hot_water', label: 'Hot water' },
|
||||
]} />
|
||||
<Select label="Bind source (optional)" value={sourceUuid} onChange={(value) => { setSourceUuid(value); setChannelUuid(null) }} data={sources.data?.items.filter((source) => typeof source.uuid === 'string').map((source) => ({ value: source.uuid, label: source.name })) ?? []} />
|
||||
{sourceUuid && <Select label="Compatible source channel (optional)" value={channelUuid} onChange={setChannelUuid} description="Only unbound channels with the required unit are eligible. Suggestions are informational only." data={channels.data?.items.filter(compatible).map((channel) => ({ value: channel.uuid, label: `${channel.label} (${channel.unit})${channel.suggested_commodity ? ` — suggestion: ${channel.suggested_commodity}` : ''}` })) ?? []} />}
|
||||
{sourceUuid && <Select label="Compatible source channel (optional)" value={selectedChannelUuid} onChange={setChannelUuid} description={reason === 'meter_swap' ? 'An unbound channel, or the current same-commodity meter’s single binding, can be selected. Other occupied channels stay unavailable.' : 'Only unbound channels with the required unit are eligible.'} data={channelOptions} />}
|
||||
|
||||
<Textarea
|
||||
label="Note (optional)"
|
||||
@@ -534,6 +574,7 @@ export function MeterManager() {
|
||||
{/* Declare new meter */}
|
||||
{showDeclareForm && (
|
||||
<DeclareMeterForm
|
||||
meters={meters}
|
||||
onClose={() => setShowDeclareForm(false)}
|
||||
onSaved={() => setShowDeclareForm(false)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user