diff --git a/frontend/src/components/KioskDeviceBlock.tsx b/frontend/src/components/KioskDeviceBlock.tsx deleted file mode 100644 index bba0295..0000000 --- a/frontend/src/components/KioskDeviceBlock.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Thermometer, Snowflake, Flame, Fan, Droplet, Heater, Check } from 'lucide-react' -import type { DeviceSummary } from '../types' - -const MODE_ICONS: Record = { - cool: Snowflake, - heat: Flame, - fan: Fan, - dry: Droplet, - auto: Fan, -} - -function fmtTemp(t: string | number | null | undefined) { - return t != null ? `${Number(t).toFixed(1)}°C` : '—' -} - -interface Props { - device: DeviceSummary - zoneName: string - selectionMode: boolean - selected: boolean - onTap: () => void -} - -// Small summary block — mode/power + setpoint at a glance, tap for the full -// control modal (or toggle selection, in bulk-select mode). Deliberately reads -// from the same live-status cache /api/status already returns (see Kiosk.tsx), -// not a per-device round trip — this is what makes a scrollable grid of many of -// these cheap to poll. -export default function KioskDeviceBlock({ device, zoneName, selectionMode, selected, onTap }: Props) { - const isTrv = device.device_type === 'shelly_trv' - const ModeIcon = isTrv ? Heater : (MODE_ICONS[device.current_mode || ''] || Fan) - const on = isTrv ? !!device.heating_active : device.power_state === 'on' - - return ( -
- {selectionMode && ( -
{selected && }
- )} -
- {zoneName} - {device.location && · {device.location}} -
-
- - {fmtTemp(device.current_target_temp)} -
-
- {fmtTemp(device.current_room_temp)} - {!isTrv && device.fan_speed && {device.fan_speed}} -
-
- ) -} diff --git a/frontend/src/components/KioskZoneBlock.tsx b/frontend/src/components/KioskZoneBlock.tsx new file mode 100644 index 0000000..ff75738 --- /dev/null +++ b/frontend/src/components/KioskZoneBlock.tsx @@ -0,0 +1,84 @@ +import { Snowflake, Flame, Fan, Droplet, Heater, Lock, Check } from 'lucide-react' +import type { ZoneStatus, DeviceSummary } from '../types' +import { MHI_FAN_SPEEDS, KIOSK_AC_DEVICE_TYPES } from '../types' + +const MODE_ICONS: Record = { + cool: Snowflake, + heat: Flame, + fan: Fan, + dry: Droplet, + auto: Fan, +} + +// bed -> living/lounge -> bathroom, same ordering as ZoneCard.tsx's rad-row. +function locationRank(location: string | null) { + const l = (location || '').toLowerCase() + if (l.includes('bed')) return 0 + if (l.includes('living') || l.includes('lounge')) return 1 + if (l.includes('bath')) return 2 + return 3 +} + +function fmtTemp(t: string | number | null | undefined) { + return t != null ? `${Number(t).toFixed(1)}°C` : '—' +} + +interface Props { + zone: ZoneStatus + liveDevices: DeviceSummary[] // this zone's devices, already filtered to KIOSK_DEVICE_TYPES + selectionMode: boolean + selected: boolean + onTap: () => void +} + +// One tile per ROOM/ZONE — same idea as the Dashboard's ZoneCard, not one tile +// per device. A room with both a TRV and an AC unit shows both in one tile, via +// the exact same rad-row/ac-block markup+CSS ZoneCard.tsx uses, so the kiosk +// visually matches the staff dashboard (per user correction, 2026-08-26, to an +// earlier per-device-tile version of this file). +export default function KioskZoneBlock({ zone, liveDevices, selectionMode, selected, onTap }: Props) { + const trvs = liveDevices + .filter(d => d.device_type === 'shelly_trv') + .sort((a, b) => locationRank(a.location) - locationRank(b.location)) + const ac = liveDevices.find(d => KIOSK_AC_DEVICE_TYPES.includes(d.device_type)) + const acOn = ac?.power_state === 'on' + const ModeIcon = MODE_ICONS[ac?.current_mode || ''] || Fan + const fanLevel = ac ? (MHI_FAN_SPEEDS as readonly string[]).indexOf(ac.fan_speed || '') + 1 : 0 + + return ( +
+ {selectionMode && ( +
{selected && }
+ )} +
{zone.name}
+ +
+ {trvs.length > 0 && ( +
+ {trvs.slice(0, 3).map(d => ( +
+ + {fmtTemp(d.current_target_temp)} +
+ ))} +
+ )} + + {ac && ( +
+ {ac.remote_locked && } +
+ +
+ {[1, 2, 3, 4].map(n => )} +
+
+
{fmtTemp(ac.current_target_temp)} / {fmtTemp(ac.current_room_temp)}
+
+ )} + + {trvs.length === 0 && !ac && No live devices} +
+
+ ) +} diff --git a/frontend/src/components/KioskZonePanel.tsx b/frontend/src/components/KioskZonePanel.tsx new file mode 100644 index 0000000..1a9695f --- /dev/null +++ b/frontend/src/components/KioskZonePanel.tsx @@ -0,0 +1,27 @@ +import type { DeviceSummary } from '../types' +import MhiControlPanel from './MhiControlPanel' +import DaikinControlPanel from './DaikinControlPanel' +import TrvControlPanel from './TrvControlPanel' + +// Single-zone edit modal content — one card per live device in this zone, each +// with its normal control panel. Same "loop the zone's devices, render the +// matching control panel" pattern as ZoneDetailModal.tsx, minus the staff-only +// schedule/override sections (kiosk isn't for editing scheduling config). +export default function KioskZonePanel({ devices }: { devices: DeviceSummary[] }) { + if (devices.length === 0) { + return

No live-controllable devices in this zone.

+ } + + return ( +
+ {devices.map(d => ( +
+
{d.location || d.discovered_name || d.external_ref}
+ {d.device_type === 'shelly_trv' && } + {d.device_type === 'mhi_mqtt' && } + {d.device_type === 'daikin' && } +
+ ))} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8d877df..dbb4b40 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -456,13 +456,15 @@ table.data tr:last-child td { border-bottom: none; } .kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 16px; } .kiosk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } +/* One tile per zone, reusing ZoneCard's own rad-row/ac-block/fan-bar classes + (defined above under "Zone grid / cards") so a room's TRV + AC read + identically here and on the Dashboard — just the outer tile is kiosk-sized. */ .kiosk-block { position: relative; cursor: pointer; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: 12px; min-height: 96px; - display: flex; flex-direction: column; gap: 6px; user-select: none; + display: flex; flex-direction: column; gap: 8px; user-select: none; } -.kiosk-block.on { border-color: var(--app-primary); } .kiosk-block.selected { outline: 2px solid var(--gold); outline-offset: -2px; } .kiosk-block-check { position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; border-radius: 5px; @@ -470,12 +472,8 @@ table.data tr:last-child td { border-bottom: none; } color: var(--navy); background: var(--card-bg); } .kiosk-block.selected .kiosk-block-check { background: var(--gold); border-color: var(--gold); color: var(--navy); } -.kiosk-block-zone { font-size: 12px; font-weight: 700; color: var(--text-dark); padding-right: 22px; } -.kiosk-block-location { font-weight: 500; color: var(--text-mid); } -.kiosk-block-main { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 700; color: var(--text-dark); } -.kiosk-block.on .kiosk-block-main { color: var(--app-primary); } -.kiosk-block-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--text-mid); } -.kiosk-block-fan { margin-left: auto; text-transform: capitalize; } +.kiosk-block-zone { font-size: 13px; font-weight: 700; color: var(--text-dark); padding-right: 22px; } +.kiosk-block-body { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex: 1; } .kiosk-bulk-bar { position: sticky; bottom: 0; display: flex; align-items: center; gap: 12px; diff --git a/frontend/src/pages/Kiosk.tsx b/frontend/src/pages/Kiosk.tsx index 9dc7c74..b9c8629 100644 --- a/frontend/src/pages/Kiosk.tsx +++ b/frontend/src/pages/Kiosk.tsx @@ -3,48 +3,39 @@ import { Thermometer, CheckSquare, X } from 'lucide-react' import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api' import type { DeviceSummary, ZoneStatus } from '../types' import { KIOSK_DEVICE_TYPES } from '../types' -import KioskDeviceBlock from '../components/KioskDeviceBlock' +import KioskZoneBlock from '../components/KioskZoneBlock' +import KioskZonePanel from '../components/KioskZonePanel' import KioskModal from '../components/KioskModal' import KioskBulkPanel from '../components/KioskBulkPanel' -import TrvControlPanel from '../components/TrvControlPanel' -import MhiControlPanel from '../components/MhiControlPanel' -import DaikinControlPanel from '../components/DaikinControlPanel' // Control surface, not a passive display — far shorter than weather's 5-minute // precedent, but not so aggressive it hammers the backend given the underlying // data is already push/poll-cached (MQTT for TRV/MHI, a 60s poller for Daikin). const POLL_MS = 12_000 -interface FlatDevice { device: DeviceSummary; zoneName: string } - -function flattenDevices(zones: ZoneStatus[]): FlatDevice[] { - const out: FlatDevice[] = [] - for (const z of zones) { - for (const d of z.devices) { - if (KIOSK_DEVICE_TYPES.includes(d.device_type)) out.push({ device: d, zoneName: z.name }) - } - } - return out.sort((a, b) => a.zoneName.localeCompare(b.zoneName) || (a.device.location || '').localeCompare(b.device.location || '')) +function liveDevicesOf(zone: ZoneStatus): DeviceSummary[] { + return zone.devices.filter(d => KIOSK_DEVICE_TYPES.includes(d.device_type)) } // The wall-panel kiosk view — a dedicated route/login, deliberately outside the -// normal staff AuthGate/Layout (see App.tsx). One master panel: every live- -// controllable device (shelly_trv / mhi_mqtt / daikin) in one scrollable grid of -// small blocks; tap a block for the full control modal, or toggle Select for -// type-aware bulk editing. See project_hvac_kiosk memory for the design decisions -// behind this (access model, layout, bulk-edit mechanism). +// normal staff AuthGate/Layout (see App.tsx). One master panel: one tile per +// ROOM/ZONE (same as the Dashboard's ZoneCard — a room's TRV and AC show +// together, not as separate tiles) in a scrollable grid; tap a tile for that +// zone's full control modal, or toggle Select for type-aware bulk editing +// across zones. See project_hvac_kiosk memory for the design decisions behind +// this (access model, layout, bulk-edit mechanism). export default function Kiosk() { const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking') const [password, setPassword] = useState('') const [loginError, setLoginError] = useState('') const [loginLoading, setLoginLoading] = useState(false) - const [flatDevices, setFlatDevices] = useState([]) + const [zones, setZones] = useState([]) const [loadError, setLoadError] = useState('') const [selectionMode, setSelectionMode] = useState(false) - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [editingDevice, setEditingDevice] = useState(null) + const [selectedZoneIds, setSelectedZoneIds] = useState>(new Set()) + const [editingZone, setEditingZone] = useState(null) const [bulkOpen, setBulkOpen] = useState(false) const pollRef = useRef | null>(null) @@ -52,7 +43,7 @@ export default function Kiosk() { const load = useCallback(() => { kioskFetchStatus() .then(s => { - setFlatDevices(flattenDevices(s.zones)) + setZones(s.zones.filter(z => liveDevicesOf(z).length > 0)) setLoadError('') setAuthState('authed') }) @@ -89,30 +80,31 @@ export default function Kiosk() { function toggleSelectionMode() { setSelectionMode(m => !m) - setSelectedIds(new Set()) + setSelectedZoneIds(new Set()) } - function tapDevice(d: DeviceSummary) { + function tapZone(zone: ZoneStatus) { if (selectionMode) { - setSelectedIds(prev => { + setSelectedZoneIds(prev => { const next = new Set(prev) - if (next.has(d.id)) next.delete(d.id); else next.add(d.id) + if (next.has(zone.id)) next.delete(zone.id); else next.add(zone.id) return next }) } else { - setEditingDevice(d) + setEditingZone(zone) } } function closeBulk() { setBulkOpen(false) setSelectionMode(false) - setSelectedIds(new Set()) + setSelectedZoneIds(new Set()) load() } - const selectedDevices = flatDevices.filter(fd => selectedIds.has(fd.device.id)).map(fd => fd.device) - const editingZoneName = editingDevice ? flatDevices.find(fd => fd.device.id === editingDevice.id)?.zoneName : '' + // Bulk edit works across whichever zones are selected — gather every live + // device from all of them, KioskBulkPanel sections by device type from there. + const selectedDevices = zones.filter(z => selectedZoneIds.has(z.id)).flatMap(liveDevicesOf) if (authState === 'checking') { return

Loading…

@@ -154,45 +146,40 @@ export default function Kiosk() {
- {flatDevices.map(({ device, zoneName }) => ( - ( + tapDevice(device)} + selected={selectedZoneIds.has(zone.id)} + onTap={() => tapZone(zone)} /> ))} - {flatDevices.length === 0 && !loadError && ( + {zones.length === 0 && !loadError && (

No live-controllable devices assigned to a zone yet.

)}
- {selectionMode && selectedIds.size > 0 && ( + {selectionMode && selectedZoneIds.size > 0 && (
- {selectedIds.size} selected + {selectedZoneIds.size} zone{selectedZoneIds.size === 1 ? '' : 's'} selected -
)} - {editingDevice && ( - { setEditingDevice(null); load() }} - > - {editingDevice.device_type === 'shelly_trv' && } - {editingDevice.device_type === 'mhi_mqtt' && } - {editingDevice.device_type === 'daikin' && } + {editingZone && ( + { setEditingZone(null); load() }}> + )} {bulkOpen && ( - + )}