import { useEffect, useState } from 'react' import { X, Thermometer, History } from 'lucide-react' import type { ZoneStatus, ActivityEntry, HvacDefaults } from '../types' import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, can } from '../types' import { useAuth } from './AuthGate' import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api' import MhiControlPanel from './MhiControlPanel' // The 12 fields a room can individually override — each is `null` on the zone when // inheriting the matching field from the global hvac_defaults template (Settings page), // or an explicit value when overridden. Mirrors the OVERRIDABLE_FIELDS list in // backend/src/routes/zones.js. type OverridableField = | 'occupied_temp' | 'vacant_temp' | 'heating_offset_min' | 'cooling_offset_min' | 'ac_occupied_power' | 'ac_occupied_mode' | 'ac_occupied_setpoint' | 'ac_occupied_fan_speed' | 'ac_vacant_power' | 'ac_vacant_mode' | 'ac_vacant_setpoint' | 'ac_vacant_fan_speed' function defaultFor(field: OverridableField, d: HvacDefaults): string | number { switch (field) { case 'occupied_temp': return d.trv_occupied_temp case 'vacant_temp': return d.trv_vacant_temp case 'heating_offset_min': return d.arrival_prep_offset_min case 'cooling_offset_min': return d.departure_prep_offset_min case 'ac_occupied_power': return d.ac_occupied.power case 'ac_occupied_mode': return d.ac_occupied.mode case 'ac_occupied_setpoint': return d.ac_occupied.setpoint case 'ac_occupied_fan_speed': return d.ac_occupied.fan_speed case 'ac_vacant_power': return d.ac_vacant.power case 'ac_vacant_mode': return d.ac_vacant.mode case 'ac_vacant_setpoint': return d.ac_vacant.setpoint case 'ac_vacant_fan_speed': return d.ac_vacant.fan_speed } } // Checkbox + either the override input (children) or the inherited default value, // depending on whether this field is currently overridden. function OverrideCell({ checked, defaultLabel, onToggle, children }: { checked: boolean defaultLabel: string onToggle: (checked: boolean) => void children: React.ReactNode }) { return (
{checked ? children : {defaultLabel}}
) } export default function ZoneDetailModal({ zone, onClose, onSaved }: { zone: ZoneStatus onClose: () => void onSaved: () => void }) { const { user } = useAuth() const canEdit = can(user, 'schedule_edit') const canControl = can(user, 'control') const [hvacDefaults, setHvacDefaults] = useState(DEFAULT_HVAC_TEMPLATE) const [overrides, setOverrides] = useState>({ occupied_temp: zone.occupied_temp, vacant_temp: zone.vacant_temp, heating_offset_min: zone.heating_offset_min, cooling_offset_min: zone.cooling_offset_min, ac_occupied_power: zone.ac_occupied_power, ac_occupied_mode: zone.ac_occupied_mode, ac_occupied_setpoint: zone.ac_occupied_setpoint, ac_occupied_fan_speed: zone.ac_occupied_fan_speed, ac_vacant_power: zone.ac_vacant_power, ac_vacant_mode: zone.ac_vacant_mode, ac_vacant_setpoint: zone.ac_vacant_setpoint, ac_vacant_fan_speed: zone.ac_vacant_fan_speed, }) const [autoMode, setAutoMode] = useState(zone.auto_mode) const [syncValves, setSyncValves] = useState(zone.sync_valves) const [excludeBathroom, setExcludeBathroom] = useState(zone.exclude_bathroom) const [overrideTemp, setOverrideTemp] = useState(zone.occupied_temp ?? String(hvacDefaults.trv_occupied_temp)) const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [msg, setMsg] = useState('') const [activity, setActivity] = useState([]) useEffect(() => { fetchActivity(zone.id, 20).then(setActivity).catch(() => {}) fetchConfig().then(cfg => setHvacDefaults(cfg.hvac_defaults ?? DEFAULT_HVAC_TEMPLATE)).catch(() => {}) }, [zone.id]) function toggleOverride(field: OverridableField, checked: boolean) { setOverrides(o => ({ ...o, [field]: checked ? defaultFor(field, hvacDefaults) : null })) } function setOverrideValue(field: OverridableField, value: string | number) { setOverrides(o => ({ ...o, [field]: value })) } async function save() { setSaving(true); setError(''); setMsg('') try { await updateZone(zone.id, { occupied_temp: overrides.occupied_temp != null ? Number(overrides.occupied_temp) : null, vacant_temp: overrides.vacant_temp != null ? Number(overrides.vacant_temp) : null, heating_offset_min: overrides.heating_offset_min != null ? Number(overrides.heating_offset_min) : null, cooling_offset_min: overrides.cooling_offset_min != null ? Number(overrides.cooling_offset_min) : null, ac_occupied_power: overrides.ac_occupied_power || null, ac_occupied_mode: overrides.ac_occupied_mode || null, ac_occupied_setpoint: overrides.ac_occupied_setpoint != null ? Number(overrides.ac_occupied_setpoint) : null, ac_occupied_fan_speed: overrides.ac_occupied_fan_speed || null, ac_vacant_power: overrides.ac_vacant_power || null, ac_vacant_mode: overrides.ac_vacant_mode || null, ac_vacant_setpoint: overrides.ac_vacant_setpoint != null ? Number(overrides.ac_vacant_setpoint) : null, ac_vacant_fan_speed: overrides.ac_vacant_fan_speed || null, auto_mode: autoMode, sync_valves: syncValves, exclude_bathroom: excludeBathroom, }) setMsg('Saved') onSaved() } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') } finally { setSaving(false) } } async function forceTemp() { setSaving(true); setError(''); setMsg('') try { const res = await overrideZone(zone.id, Number(overrideTemp)) if (res.ok) { setMsg(`Set to ${overrideTemp}°C (${res.successful}/${res.total} devices) — auto mode disabled`) setAutoMode(false) onSaved() } else { setError(res.error || 'Override failed') } } catch (e) { setError(e instanceof Error ? e.message : 'Override failed') } finally { setSaving(false) } } return (
e.stopPropagation()}>

{zone.name}

{ROOM_STATE_LABELS[zone.room_state]}
{error &&
{error}
} {msg &&
{msg}
}
Devices
{zone.devices.length === 0 &&

No devices mapped to this zone yet — assign some on the Devices page.

} {zone.devices.map(d => (
{d.location || d.discovered_name || d.external_ref}
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'} {d.battery_pct != null && ` · ${d.battery_pct}% battery`}
{d.health_state.replace('_', ' ')}
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && }
))} {canControl && ( <>
Manual Override
setOverrideTemp(e.target.value)} />

Disables auto mode for this zone until re-enabled below.

)} {canEdit && ( <>
Schedule

Every value below inherits the global default (Settings → Default Templates) unless overridden here.

toggleOverride('heating_offset_min', c)} > setOverrideValue('heating_offset_min', parseInt(e.target.value) || 0)} />
toggleOverride('cooling_offset_min', c)} > setOverrideValue('cooling_offset_min', parseInt(e.target.value) || 0)} />
Aircon
OccupiedVacant
On/Off toggleOverride('ac_occupied_power', c)} > toggleOverride('ac_vacant_power', c)} >
Mode toggleOverride('ac_occupied_mode', c)} > toggleOverride('ac_vacant_mode', c)} >
Setpoint (°C) toggleOverride('ac_occupied_setpoint', c)} > setOverrideValue('ac_occupied_setpoint', parseFloat(e.target.value) || 0)} /> toggleOverride('ac_vacant_setpoint', c)} > setOverrideValue('ac_vacant_setpoint', parseFloat(e.target.value) || 0)} />
Fan speed toggleOverride('ac_occupied_fan_speed', c)} > toggleOverride('ac_vacant_fan_speed', c)} >
TRVs
toggleOverride('occupied_temp', c)} > setOverrideValue('occupied_temp', e.target.value)} />
toggleOverride('vacant_temp', c)} > setOverrideValue('vacant_temp', e.target.value)} />
)} {activity.length > 0 && ( <>
Recent activity
{activity.map(a => (
{a.note}
{new Date(a.created_at).toLocaleString()} · {a.source}
))}
)}
) }