Kiosk: tile per zone/room, not per device

Correction to the kiosk grid design (user, 2026-08-26): tiles now match the
Dashboard's ZoneCard -- one tile per room showing its TRV and AC together
(reusing ZoneCard's own rad-row/ac-block/fan-bar CSS), not a separate tile
per device. Selection/bulk-edit now operates on selected zones, gathering
every live device across them for KioskBulkPanel's type-aware sections.
Single-zone tap opens a modal listing that zone's device panels, mirroring
ZoneDetailModal's device loop minus the staff-only schedule/override UI.

- KioskZoneBlock.tsx replaces KioskDeviceBlock.tsx
- KioskZonePanel.tsx: new, one control panel per live device in a zone

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-26 10:43:28 +00:00
parent 2bae718b50
commit 930f3d867f
5 changed files with 155 additions and 116 deletions

View file

@ -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<FlatDevice[]>([])
const [zones, setZones] = useState<ZoneStatus[]>([])
const [loadError, setLoadError] = useState('')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [editingDevice, setEditingDevice] = useState<DeviceSummary | null>(null)
const [selectedZoneIds, setSelectedZoneIds] = useState<Set<number>>(new Set())
const [editingZone, setEditingZone] = useState<ZoneStatus | null>(null)
const [bulkOpen, setBulkOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 <div className="kiosk-shell kiosk-center"><p className="muted">Loading…</p></div>
@ -154,45 +146,40 @@ export default function Kiosk() {
<div className="kiosk-grid-wrap">
<div className="kiosk-grid">
{flatDevices.map(({ device, zoneName }) => (
<KioskDeviceBlock
key={device.id}
device={device}
zoneName={zoneName}
{zones.map(zone => (
<KioskZoneBlock
key={zone.id}
zone={zone}
liveDevices={liveDevicesOf(zone)}
selectionMode={selectionMode}
selected={selectedIds.has(device.id)}
onTap={() => tapDevice(device)}
selected={selectedZoneIds.has(zone.id)}
onTap={() => tapZone(zone)}
/>
))}
{flatDevices.length === 0 && !loadError && (
{zones.length === 0 && !loadError && (
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
)}
</div>
</div>
{selectionMode && selectedIds.size > 0 && (
{selectionMode && selectedZoneIds.size > 0 && (
<div className="kiosk-bulk-bar">
<span>{selectedIds.size} selected</span>
<span>{selectedZoneIds.size} zone{selectedZoneIds.size === 1 ? '' : 's'} selected</span>
<button className="btn btn-primary" onClick={() => setBulkOpen(true)}>Edit selected</button>
<button className="btn btn-sm" onClick={() => setSelectedIds(new Set())} aria-label="Clear selection">
<button className="btn btn-sm" onClick={() => setSelectedZoneIds(new Set())} aria-label="Clear selection">
<X size={13} strokeWidth={1.75} />
</button>
</div>
)}
{editingDevice && (
<KioskModal
title={editingDevice.location ? `${editingZoneName} — ${editingDevice.location}` : editingZoneName || 'Device'}
onClose={() => { setEditingDevice(null); load() }}
>
{editingDevice.device_type === 'shelly_trv' && <TrvControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'mhi_mqtt' && <MhiControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingDevice.device_type === 'daikin' && <DaikinControlPanel deviceId={editingDevice.id} canControl={true} />}
{editingZone && (
<KioskModal title={editingZone.name} onClose={() => { setEditingZone(null); load() }}>
<KioskZonePanel devices={liveDevicesOf(editingZone)} />
</KioskModal>
)}
{bulkOpen && (
<KioskModal title={`Edit ${selectedDevices.length} selected`} wide onClose={closeBulk}>
<KioskModal title={`Edit ${selectedZoneIds.size} zone${selectedZoneIds.size === 1 ? '' : 's'}`} wide onClose={closeBulk}>
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
</KioskModal>
)}